diff --git a/packages/core/types/src/dml/index.ts b/packages/core/types/src/dml/index.ts index 133e748b6a..299c099995 100644 --- a/packages/core/types/src/dml/index.ts +++ b/packages/core/types/src/dml/index.ts @@ -1,8 +1,11 @@ export const IsDmlEntity = Symbol.for("isDmlEntity") -export interface IDmlEntity< - Schema extends Record | RelationshipType> -> { +export type DMLSchema = Record< + string, + PropertyType | RelationshipType +> + +export interface IDmlEntity { [IsDmlEntity]: true schema: Schema } @@ -163,3 +166,23 @@ export type InferTypeOf> = InstanceType> export type InferEntityType = T extends IDmlEntity ? InferTypeOf : T + +/** + * Infer all indexable properties from a DML entity including inferred foreign keys and excluding relationship + */ +export type InferIndexableProperties = keyof (T extends IDmlEntity< + infer Schema +> + ? { + [K in keyof Schema as Schema[K] extends RelationshipType + ? never + : K]: string + } & InferForeignKeys + : never) + +export type EntityIndex = { + name?: string + unique?: boolean + on: InferIndexableProperties>[] + where?: string +} 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 d69d5ec30f..2ce81e850b 100644 --- a/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts +++ b/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts @@ -14,6 +14,41 @@ describe("Entity builder", () => { MetadataStorage.clear() }) + const defaultColumnMetadata = { + created_at: { + columnType: "timestamptz", + defaultRaw: "now()", + getter: false, + name: "created_at", + nullable: false, + onCreate: expect.any(Function), + reference: "scalar", + setter: false, + type: "date", + }, + deleted_at: { + columnType: "timestamptz", + getter: false, + name: "deleted_at", + nullable: true, + reference: "scalar", + setter: false, + type: "date", + }, + updated_at: { + columnType: "timestamptz", + defaultRaw: "now()", + getter: false, + name: "updated_at", + nullable: false, + onCreate: expect.any(Function), + onUpdate: expect.any(Function), + reference: "scalar", + setter: false, + type: "date", + }, + } + describe("Entity builder | properties", () => { test("should identify a DML entity correctly", () => { const user = model.define("user", { @@ -2484,6 +2519,145 @@ describe("Entity builder", () => { }) }) + describe("Entity builder | indexes", () => { + test("should define indexes for an entity", () => { + const group = model.define("group", { + id: model.number(), + name: model.text(), + users: model.hasMany(() => user), + }) + + const user = model + .define("user", { + email: model.text(), + account: model.text(), + organization: model.text(), + group: model.belongsTo(() => group, { mappedBy: "users" }), + }) + .indexes([ + { + unique: true, + on: ["email", "account"], + }, + { on: ["email", "account"] }, + { + on: ["organization", "account"], + where: "email IS NOT NULL", + }, + { + name: "IDX_unique-name", + unique: true, + on: ["organization", "account", "group_id"], + }, + ]) + + const User = toMikroORMEntity(user) + const metaData = MetadataStorage.getMetadataFromDecorator(User) + + expect(metaData.properties).toEqual({ + email: { + reference: "scalar", + type: "string", + columnType: "text", + name: "email", + nullable: false, + getter: false, + setter: false, + }, + account: { + reference: "scalar", + type: "string", + columnType: "text", + name: "account", + nullable: false, + getter: false, + setter: false, + }, + organization: { + columnType: "text", + getter: false, + name: "organization", + nullable: false, + reference: "scalar", + setter: false, + type: "string", + }, + group: { + entity: "Group", + name: "group", + nullable: false, + persist: false, + reference: "m:1", + }, + group_id: { + columnType: "text", + entity: "Group", + fieldName: "group_id", + mapToPk: true, + name: "group_id", + nullable: false, + onDelete: undefined, + reference: "m:1", + }, + ...defaultColumnMetadata, + }) + + expect(metaData.indexes).toEqual([ + { + expression: + 'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_user_email_account_unique" ON "user" (email, account) WHERE deleted_at IS NULL', + name: "IDX_user_email_account_unique", + }, + { + expression: + 'CREATE INDEX IF NOT EXISTS "IDX_user_email_account" ON "user" (email, account) WHERE deleted_at IS NULL', + name: "IDX_user_email_account", + }, + { + expression: + 'CREATE INDEX IF NOT EXISTS "IDX_user_organization_account" ON "user" (organization, account) WHERE email IS NOT NULL AND deleted_at IS NULL', + name: "IDX_user_organization_account", + }, + { + expression: + 'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique-name" ON "user" (organization, account, group_id) WHERE deleted_at IS NULL', + name: "IDX_unique-name", + }, + ]) + }) + + test("should throw an error if field is unknown for an index", () => { + try { + const group = model.define("group", { + id: model.number(), + name: model.text(), + users: model.hasMany(() => user), + }) + + const user = model + .define("user", { + email: model.text(), + account: model.text(), + organization: model.text(), + group: model.belongsTo(() => group, { mappedBy: "users" }), + }) + .indexes([ + { + on: ["email", "account", "doesnotexist", "anotherdoesnotexist"], + }, + ]) + + toMikroORMEntity(user) + + throw "should not reach" + } catch (e) { + expect(e.message).toEqual( + "Fields (doesnotexist, anotherdoesnotexist) are not found when applying indexes from DML entity" + ) + } + }) + }) + describe("Entity builder | hasMany", () => { test("define hasMany relationship", () => { const email = model.define("email", { diff --git a/packages/core/utils/src/dml/entity-builder.ts b/packages/core/utils/src/dml/entity-builder.ts index e54a76b51b..2b9a23088d 100644 --- a/packages/core/utils/src/dml/entity-builder.ts +++ b/packages/core/utils/src/dml/entity-builder.ts @@ -1,12 +1,9 @@ -import type { - PropertyType, - RelationshipOptions, - RelationshipType, -} from "@medusajs/types" +import type { DMLSchema, RelationshipOptions } from "@medusajs/types" import { DmlEntity } from "./entity" import { createBigNumberProperties } from "./helpers/entity-builder/create-big-number-properties" import { createDefaultProperties } from "./helpers/entity-builder/create-default-properties" import { inferPrimaryKeyProperties } from "./helpers/entity-builder/infer-primary-key-properties" +import { ArrayProperty } from "./properties/array" import { BigNumberProperty } from "./properties/big-number" import { BooleanProperty } from "./properties/boolean" import { DateTimeProperty } from "./properties/date-time" @@ -19,18 +16,12 @@ 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 */ const IMPLICIT_PROPERTIES = ["created_at", "updated_at", "deleted_at"] -export type DMLSchema = Record< - string, - PropertyType | RelationshipType -> - type DefineOptions = string | { name?: string; tableName: string } /** diff --git a/packages/core/utils/src/dml/entity.ts b/packages/core/utils/src/dml/entity.ts index 12070f01ca..2c536e84bb 100644 --- a/packages/core/utils/src/dml/entity.ts +++ b/packages/core/utils/src/dml/entity.ts @@ -1,14 +1,14 @@ import { + DMLSchema, EntityCascades, + EntityIndex, ExtractEntityRelations, IDmlEntity, IsDmlEntity, - PropertyType, - RelationshipType, } from "@medusajs/types" -import { DMLSchema } from "./entity-builder" -import { BelongsTo } from "./relations/belongs-to" import { isObject, isString, toCamelCase } from "../common" +import { transformIndexWhere } from "./helpers/entity-builder/build-indexes" +import { BelongsTo } from "./relations/belongs-to" type Config = string | { name?: string; tableName: string } @@ -54,6 +54,7 @@ export class DmlEntity implements IDmlEntity { readonly #tableName: string #cascades: EntityCascades = {} + #indexes: EntityIndex[] = [] constructor(nameOrConfig: Config, public schema: Schema) { const { name, tableName } = extractNameAndTableName(nameOrConfig) @@ -78,16 +79,16 @@ export class DmlEntity implements IDmlEntity { parse(): { name: string tableName: string - schema: PropertyType | RelationshipType + schema: DMLSchema cascades: EntityCascades + indexes: EntityIndex[] } { return { name: this.name, tableName: this.#tableName, - schema: this.schema as unknown as - | PropertyType - | RelationshipType, + schema: this.schema, cascades: this.#cascades, + indexes: this.#indexes, } } @@ -119,4 +120,14 @@ export class DmlEntity implements IDmlEntity { this.#cascades = options return this } + + indexes(indexes: EntityIndex[]) { + for (const index of indexes) { + index.where = transformIndexWhere(index) + index.unique = index.unique ?? false + } + + this.#indexes = indexes + return this + } } 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 de3e0e589a..006fb14d5d 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 @@ -2,9 +2,12 @@ import type { EntityConstructor, Infer } from "@medusajs/types" import { Entity, Filter } from "@mikro-orm/core" import { mikroOrmSoftDeletableFilterOptions } from "../../dal" import { DmlEntity } from "../entity" -import { defineProperty } from "./entity-builder/define-property" -import { applyIndexes } from "./entity-builder/apply-indexes" +import { + applyEntityIndexes, + applyIndexes, +} from "./entity-builder/apply-indexes" import { applySearchable } from "./entity-builder/apply-searchable" +import { defineProperty } from "./entity-builder/define-property" import { defineRelationship } from "./entity-builder/define-relationship" import { parseEntityName } from "./entity-builder/parse-entity-name" @@ -37,7 +40,7 @@ export function createMikrORMEntity() { return function createEntity>(entity: T): Infer { class MikroORMEntity {} - const { schema, cascades } = entity.parse() + const { schema, cascades, indexes: entityIndexes = [] } = entity.parse() const { modelName, tableName } = parseEntityName(entity) /** @@ -69,6 +72,10 @@ export function createMikrORMEntity() { } }) + if (entityIndexes?.length) { + applyEntityIndexes(MikroORMEntity, tableName, entityIndexes) + } + /** * Converting class to a MikroORM entity */ 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 index 15c268a2f3..82437b501c 100644 --- a/packages/core/utils/src/dml/helpers/entity-builder/apply-indexes.ts +++ b/packages/core/utils/src/dml/helpers/entity-builder/apply-indexes.ts @@ -1,5 +1,10 @@ -import { EntityConstructor, PropertyMetadata } from "@medusajs/types" +import { + EntityConstructor, + EntityIndex, + PropertyMetadata, +} from "@medusajs/types" import { createPsqlIndexStatementHelper } from "../../../common" +import { validateIndexFields } from "../mikro-orm/build-indexes" /** * Prepares indexes for a given field @@ -20,3 +25,26 @@ export function applyIndexes( providerEntityIdIndexStatement.MikroORMIndex()(MikroORMEntity) }) } + +/** + * Prepares indexes for a given field + */ +export function applyEntityIndexes( + MikroORMEntity: EntityConstructor, + tableName: string, + indexes: EntityIndex[] +) { + indexes.forEach((index) => { + validateIndexFields(MikroORMEntity, index) + + const providerEntityIdIndexStatement = createPsqlIndexStatementHelper({ + tableName, + name: index.name, + columns: index.on as string[], + unique: index.unique, + where: index.where, + }) + + providerEntityIdIndexStatement.MikroORMIndex()(MikroORMEntity) + }) +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/build-indexes.ts b/packages/core/utils/src/dml/helpers/entity-builder/build-indexes.ts new file mode 100644 index 0000000000..c091813e31 --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/build-indexes.ts @@ -0,0 +1,30 @@ +import { DMLSchema, EntityIndex } from "@medusajs/types" +import { isPresent } from "../../../common" + +/* + The DML provides an opinionated soft deletable entity as a part of every model + We assume that deleted_at would be scoped in indexes in all cases as an index without the scope + doesn't seem to be valid. If a case presents itself where one would like to remove the scope, + this will need to be updated to include that case. +*/ +export function transformIndexWhere( + index: EntityIndex +) { + let where = index.where + const lowerCaseWhere = where?.toLowerCase() + const whereIncludesDeleteable = + lowerCaseWhere?.includes("deleted_at is null") || + lowerCaseWhere?.includes("deleted_at is not null") + + // If where scope does not include a deleted_at scope, we add a soft deletable scope to it + if (where && !whereIncludesDeleteable) { + where = where + ` AND deleted_at IS NULL` + } + + // If where scope isn't present, we will set an opinionated where scope to the index + if (!isPresent(where)) { + where = "deleted_at IS NULL" + } + + return where +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/create-big-number-properties.ts b/packages/core/utils/src/dml/helpers/entity-builder/create-big-number-properties.ts index dcd525d489..4a572cfdc0 100644 --- a/packages/core/utils/src/dml/helpers/entity-builder/create-big-number-properties.ts +++ b/packages/core/utils/src/dml/helpers/entity-builder/create-big-number-properties.ts @@ -1,4 +1,4 @@ -import { DMLSchema } from "../../entity-builder" +import { DMLSchema } from "@medusajs/types" import { BigNumberProperty } from "../../properties/big-number" import { JSONProperty } from "../../properties/json" import { NullableModifier } from "../../properties/nullable" diff --git a/packages/core/utils/src/dml/helpers/entity-builder/infer-primary-key-properties.ts b/packages/core/utils/src/dml/helpers/entity-builder/infer-primary-key-properties.ts index a8d2718b70..2db9db22f3 100644 --- a/packages/core/utils/src/dml/helpers/entity-builder/infer-primary-key-properties.ts +++ b/packages/core/utils/src/dml/helpers/entity-builder/infer-primary-key-properties.ts @@ -1,4 +1,4 @@ -import { DMLSchema } from "../../entity-builder" +import { DMLSchema } from "@medusajs/types" import { IdProperty } from "../../properties/id" /* diff --git a/packages/core/utils/src/dml/helpers/mikro-orm/build-indexes.ts b/packages/core/utils/src/dml/helpers/mikro-orm/build-indexes.ts new file mode 100644 index 0000000000..20668bab45 --- /dev/null +++ b/packages/core/utils/src/dml/helpers/mikro-orm/build-indexes.ts @@ -0,0 +1,32 @@ +import { DMLSchema, EntityConstructor, EntityIndex } from "@medusajs/types" +import { MetadataStorage } from "@mikro-orm/core" +import { arrayDifference } from "../../../common" + +/* + The DML should strictly define indexes where the fields provided for the index are + already present in the schema definition. If not, we throw an error. +*/ +export function validateIndexFields( + MikroORMEntity: EntityConstructor, + index: EntityIndex +) { + const fields: string[] = index.on + + if (!fields?.length) { + throw new Error( + `"on" is a required property when applying indexes on a DML entity` + ) + } + + const metaData = MetadataStorage.getMetadataFromDecorator(MikroORMEntity) + const entityFields: string[] = Object.keys(metaData.properties) + const invalidFields = arrayDifference(fields, entityFields) + + if (invalidFields.length) { + throw new Error( + `Fields (${invalidFields.join( + ", " + )}) are not found when applying indexes from DML entity` + ) + } +}