chore: Abstract module service (#6188)

**What**
- Remove services that do not have any custom business and replace them with a simple interfaces
- Abstract module service provide the following base implementation
  - retrieve
  - list
  - listAndCount
  - delete
  - softDelete
  - restore

The above methods are created for the main model and also for each other models for which a config is provided

all method such as list, listAndCount, delete, softDelete and restore are pluralized with the model it refers to

**Migration**
- [x] product
- [x] pricing
- [x] promotion
- [x] cart
- [x] auth
- [x] customer
- [x] payment
- [x] Sales channel
- [x] Workflow-*


**Usage**

**Module**

The module service can now extend the ` ModulesSdkUtils.abstractModuleServiceFactory` which returns a class with the default implementation for each method and each model following the standard naming convention mentioned above.
This factory have 3 template arguments being the container, the main model DTO and an object representing the other model with a config object that contains at list the DTO and optionally a singular and plural property in case it needs to be set manually. It looks like the following:

```ts
export default class PricingModuleService</* ... */>
  extends ModulesSdkUtils.abstractModuleServiceFactory<
    InjectedDependencies,
    PricingTypes.PriceSetDTO,
    {
      Currency: { dto: PricingTypes.CurrencyDTO }
      MoneyAmount: { dto: PricingTypes.MoneyAmountDTO }
      PriceSetMoneyAmount: { dto: PricingTypes.PriceSetMoneyAmountDTO }
      PriceSetMoneyAmountRules: {
        dto: PricingTypes.PriceSetMoneyAmountRulesDTO
      }
      PriceRule: { dto: PricingTypes.PriceRuleDTO }
      RuleType: { dto: PricingTypes.RuleTypeDTO }
      PriceList: { dto: PricingTypes.PriceListDTO }
      PriceListRule: { dto: PricingTypes.PriceListRuleDTO }
    }
  >(PriceSet, generateMethodForModels, entityNameToLinkableKeysMap)
  implements PricingTypes.IPricingModuleService
{
// ...
}
```

In the above, the singular and plural can be inferred as there is no tricky naming. Also, the default implementation does not remove the fact that you need to provides all the overloads etc in your module service interface. The above will provide a default implementation following the interface `AbstractModuleService` which is also auto generated, hence you will have the following methods available:

**for the main model**
- list
- retrieve
- listAndCount 
- delete
- softDelete
- restore


**for the other models**
- list**MyModels**
- retrieve**MyModel**
- listAndCount**MyModels**
- delete**MyModels**
- softDelete**MyModels**
- restore**MyModels**

**Internal module service**

The internal module service can now extend `ModulesSdkUtils.internalModuleServiceFactory` which takes only one template argument which is the container type. 
All internal services provides a default implementation for all retrieve, list, listAndCount, create, update, delete, softDelete, restore methods which follow the following interface `ModulesSdkTypes.InternalModuleService`:

```ts
export interface InternalModuleService<
  TEntity extends {},
  TContainer extends object = object
> {
  get __container__(): TContainer

  retrieve(
    idOrObject: string,
    config?: FindConfig<any>,
    sharedContext?: Context
  ): Promise<TEntity>
  retrieve(
    idOrObject: object,
    config?: FindConfig<any>,
    sharedContext?: Context
  ): Promise<TEntity>

  list(
    filters?: FilterQuery<any> | BaseFilterable<FilterQuery<any>>,
    config?: FindConfig<any>,
    sharedContext?: Context
  ): Promise<TEntity[]>

  listAndCount(
    filters?: FilterQuery<any> | BaseFilterable<FilterQuery<any>>,
    config?: FindConfig<any>,
    sharedContext?: Context
  ): Promise<[TEntity[], number]>

  create(data: any[], sharedContext?: Context): Promise<TEntity[]>
  create(data: any, sharedContext?: Context): Promise<TEntity>

  update(data: any[], sharedContext?: Context): Promise<TEntity[]>
  update(data: any, sharedContext?: Context): Promise<TEntity>
  update(
    selectorAndData: {
      selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
      data: any
    },
    sharedContext?: Context
  ): Promise<TEntity[]>
  update(
    selectorAndData: {
      selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
      data: any
    }[],
    sharedContext?: Context
  ): Promise<TEntity[]>

  delete(idOrSelector: string, sharedContext?: Context): Promise<void>
  delete(idOrSelector: string[], sharedContext?: Context): Promise<void>
  delete(idOrSelector: object, sharedContext?: Context): Promise<void>
  delete(idOrSelector: object[], sharedContext?: Context): Promise<void>
  delete(
    idOrSelector: {
      selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
    },
    sharedContext?: Context
  ): Promise<void>

  softDelete(
    idsOrFilter: string[] | InternalFilterQuery,
    sharedContext?: Context
  ): Promise<[TEntity[], Record<string, unknown[]>]>

  restore(
    idsOrFilter: string[] | InternalFilterQuery,
    sharedContext?: Context
  ): Promise<[TEntity[], Record<string, unknown[]>]>

  upsert(data: any[], sharedContext?: Context): Promise<TEntity[]>
  upsert(data: any, sharedContext?: Context): Promise<TEntity>
}
```

When a service is auto generated you can use that interface to type your class property representing the expected internal service.

**Repositories**

The repositories can now extend `DALUtils.mikroOrmBaseRepositoryFactory` which takes one template argument being the entity or the template entity and provides all the default implementation. If the repository is auto generated you can type it using the `RepositoryService` interface. Here is the new interface typings.

```ts
export interface RepositoryService<T = any> extends BaseRepositoryService<T> {
  find(options?: FindOptions<T>, context?: Context): Promise<T[]>

  findAndCount(
    options?: FindOptions<T>,
    context?: Context
  ): Promise<[T[], number]>

  create(data: any[], context?: Context): Promise<T[]>

  // Becareful here, if you have a custom internal service, the update data should never be the entity otherwise
 // both entity and update will point to the same ref and create issues with mikro orm
  update(data: { entity; update }[], context?: Context): Promise<T[]>

  delete(
    idsOrPKs: FilterQuery<T> & BaseFilterable<FilterQuery<T>>,
    context?: Context
  ): Promise<void>

  /**
   * Soft delete entities and cascade to related entities if configured.
   *
   * @param idsOrFilter
   * @param context
   *
   * @returns [T[], Record<string, string[]>] the second value being the map of the entity names and ids that were soft deleted
   */
  softDelete(
    idsOrFilter: string[] | InternalFilterQuery,
    context?: Context
  ): Promise<[T[], Record<string, unknown[]>]>

  restore(
    idsOrFilter: string[] | InternalFilterQuery,
    context?: Context
  ): Promise<[T[], Record<string, unknown[]>]>

  upsert(data: any[], context?: Context): Promise<T[]>
}
```
This commit is contained in:
Adrien de Peretti
2024-02-02 15:20:32 +01:00
committed by GitHub
parent abc30517cb
commit a7be5d7b6d
163 changed files with 2867 additions and 5080 deletions

View File

@@ -0,0 +1,33 @@
import { pluralize } from "../plurailze"
describe("pluralize", function () {
it("should pluralize any words", function () {
const words = [
"apple",
"box",
"day",
"country",
"baby",
"knife",
"hero",
"potato",
"address",
]
const expectedOutput = [
"apples",
"boxes",
"days",
"countries",
"babies",
"knives",
"heroes",
"potatoes",
"addresses",
]
words.forEach((word, index) => {
expect(pluralize(word)).toBe(expectedOutput[index])
})
})
})

View File

@@ -26,6 +26,7 @@ export * from "./object-from-string-path"
export * from "./object-to-string-path"
export * from "./optional-numeric-serializer"
export * from "./pick-value-from-object"
export * from "./plurailze"
export * from "./promise-all"
export * from "./remote-query-object-from-string"
export * from "./remote-query-object-to-string"

View File

@@ -0,0 +1,27 @@
/**
* Some library provide pluralize function with language specific rules.
* This is a simple implementation of pluralize function.
* @param word
*/
export function pluralize(word: string): string {
// Add basic rules for forming plurals
if (
//word.endsWith("s") ||
word.endsWith("sh") ||
word.endsWith("ss") ||
word.endsWith("ch") ||
word.endsWith("x") ||
word.endsWith("o") ||
word.endsWith("z")
) {
return word + "es"
} else if (word.endsWith("y") && !"aeiou".includes(word[word.length - 2])) {
return word.slice(0, -1) + "ies"
} else if (word.endsWith("es")) {
return word
} else if (word.endsWith("fe")) {
return word.slice(0, -2) + "ves"
} else {
return word + "s"
}
}

View File

@@ -3,5 +3,4 @@ export * from "./mikro-orm/mikro-orm-repository"
export * from "./mikro-orm/mikro-orm-soft-deletable-filter"
export * from "./mikro-orm/utils"
export * from "./repositories"
export * from "./repository"
export * from "./utils"

View File

@@ -1,6 +1,8 @@
import {
BaseFilterable,
Context,
DAL,
FilterQuery,
FilterQuery as InternalFilterQuery,
RepositoryService,
RepositoryTransformOptions,
@@ -17,11 +19,11 @@ import {
EntityName,
FilterQuery as MikroFilterQuery,
} from "@mikro-orm/core/typings"
import { MedusaError, isString } from "../../common"
import { isString } from "../../common"
import {
buildQuery,
InjectTransactionManager,
MedusaContext,
buildQuery,
} from "../../modules-sdk"
import {
getSoftDeletedCascadedEntitiesIdsMappedBy,
@@ -42,10 +44,10 @@ export class MikroOrmBase<T = any> {
: this.manager_) as unknown as TManager
}
getActiveManager<TManager = unknown>(
@MedusaContext()
{ transactionManager, manager }: Context = {}
): TManager {
getActiveManager<TManager = unknown>({
transactionManager,
manager,
}: Context = {}): TManager {
return (transactionManager ?? manager ?? this.manager_) as TManager
}
@@ -77,9 +79,10 @@ export class MikroOrmBase<T = any> {
* related ones.
*/
export class MikroOrmBaseRepository<
T extends object = object
> extends MikroOrmBase<T> {
export class MikroOrmBaseRepository<T extends object = object>
extends MikroOrmBase<T>
implements RepositoryService<T>
{
constructor(...args: any[]) {
// @ts-ignore
super(...arguments)
@@ -89,11 +92,14 @@ export class MikroOrmBaseRepository<
throw new Error("Method not implemented.")
}
update(data: unknown[], context?: Context): Promise<T[]> {
update(data: { entity; update }[], context?: Context): Promise<T[]> {
throw new Error("Method not implemented.")
}
delete(ids: string[] | object[], context?: Context): Promise<void> {
delete(
idsOrPKs: FilterQuery<T> & BaseFilterable<FilterQuery<T>>,
context?: Context
): Promise<void> {
throw new Error("Method not implemented.")
}
@@ -115,10 +121,10 @@ export class MikroOrmBaseRepository<
@InjectTransactionManager()
async softDelete(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext()
{ transactionManager: manager }: Context = {}
@MedusaContext() sharedContext: Context = {}
): Promise<[T[], Record<string, unknown[]>]> {
const isArray = Array.isArray(idsOrFilter)
// TODO handle composite keys
const filter =
isArray || isString(idsOrFilter)
? {
@@ -128,9 +134,10 @@ export class MikroOrmBaseRepository<
}
: idsOrFilter
const entities = await this.find({ where: filter as any })
const entities = await this.find({ where: filter as any }, sharedContext)
const date = new Date()
const manager = this.getActiveManager(sharedContext)
await mikroOrmUpdateDeletedAtRecursively<T>(
manager,
entities as any[],
@@ -147,9 +154,9 @@ export class MikroOrmBaseRepository<
@InjectTransactionManager()
async restore(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext()
{ transactionManager: manager }: Context = {}
@MedusaContext() sharedContext: Context = {}
): Promise<[T[], Record<string, unknown[]>]> {
// TODO handle composite keys
const isArray = Array.isArray(idsOrFilter)
const filter =
isArray || isString(idsOrFilter)
@@ -164,8 +171,9 @@ export class MikroOrmBaseRepository<
withDeleted: true,
})
const entities = await this.find(query)
const entities = await this.find(query, sharedContext)
const manager = this.getActiveManager(sharedContext)
await mikroOrmUpdateDeletedAtRecursively(manager, entities as any[], null)
const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({
@@ -228,18 +236,12 @@ export class MikroOrmBaseTreeRepository<
}
}
type DtoBasedMutationMethods = "create" | "update"
export function mikroOrmBaseRepositoryFactory<
T extends object = object,
TDTOs extends { [K in DtoBasedMutationMethods]?: any } = {
[K in DtoBasedMutationMethods]?: any
}
>(entity: EntityClass<T> | EntitySchema<T>) {
class MikroOrmAbstractBaseRepository_
extends MikroOrmBaseRepository<T>
implements RepositoryService<T, TDTOs>
{
export function mikroOrmBaseRepositoryFactory<T extends object = object>(
entity: any
): {
new ({ manager }: { manager: any }): MikroOrmBaseRepository<T>
} {
class MikroOrmAbstractBaseRepository_ extends MikroOrmBaseRepository<T> {
// @ts-ignore
constructor(...args: any[]) {
// @ts-ignore
@@ -257,7 +259,7 @@ export function mikroOrmBaseRepositoryFactory<
)
}
async create(data: TDTOs["create"][], context?: Context): Promise<T[]> {
async create(data: any[], context?: Context): Promise<T[]> {
const manager = this.getActiveManager<EntityManager>(context)
const entities = data.map((data_) => {
@@ -272,76 +274,13 @@ export function mikroOrmBaseRepositoryFactory<
return entities
}
async update(data: TDTOs["update"][], context?: Context): Promise<T[]> {
// TODO: Move this logic to the service packages/utils/src/modules-sdk/abstract-service-factory.ts
async update(data: { entity; update }[], context?: Context): Promise<T[]> {
const manager = this.getActiveManager<EntityManager>(context)
const primaryKeys =
MikroOrmAbstractBaseRepository_.retrievePrimaryKeys(entity)
let primaryKeysCriteria: { [key: string]: any }[] = []
if (primaryKeys.length === 1) {
primaryKeysCriteria.push({
[primaryKeys[0]]: data.map((d) => d[primaryKeys[0]]),
})
} else {
primaryKeysCriteria = data.map((d) => ({
$and: primaryKeys.map((key) => ({ [key]: d[key] })),
}))
}
const allEntities = await Promise.all(
primaryKeysCriteria.map(
async (criteria) =>
await this.find({ where: criteria } as DAL.FindOptions<T>, context)
)
)
const existingEntities = allEntities.flat()
const existingEntitiesMap = new Map<string, T>()
existingEntities.forEach((entity) => {
if (entity) {
const key =
MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue(
primaryKeys,
entity
)
existingEntitiesMap.set(key, entity)
}
})
const missingEntities = data.filter((data_) => {
const key =
MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue(
primaryKeys,
data_
)
return !existingEntitiesMap.has(key)
})
if (missingEntities.length) {
const entityName = (entity as EntityClass<T>).name ?? entity
const missingEntitiesKeys = data.map((data_) =>
primaryKeys.map((key) => data_[key]).join(", ")
)
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${entityName} with ${primaryKeys.join(
", "
)} "${missingEntitiesKeys.join(", ")}" not found`
)
}
const entities = data.map((data_) => {
const key =
MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue(
primaryKeys,
data_
)
const existingEntity = existingEntitiesMap.get(key)!
return manager.assign(existingEntity, data_ as RequiredEntityData<T>)
return manager.assign(
data_.entity,
data_.update as RequiredEntityData<T>
)
})
manager.persist(entities)
@@ -350,40 +289,11 @@ export function mikroOrmBaseRepositoryFactory<
}
async delete(
primaryKeyValues: string[] | object[],
filters: FilterQuery<T> & BaseFilterable<FilterQuery<T>>,
context?: Context
): Promise<void> {
const manager = this.getActiveManager<EntityManager>(context)
const primaryKeys =
MikroOrmAbstractBaseRepository_.retrievePrimaryKeys(entity)
let deletionCriteria
if (primaryKeys.length > 1) {
deletionCriteria = {
$or: primaryKeyValues.map((compositeKeyValue) => {
const keys = Object.keys(compositeKeyValue)
if (!primaryKeys.every((k) => keys.includes(k))) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Composite key must contain all primary key fields: ${primaryKeys.join(
", "
)}. Found: ${keys}`
)
}
const criteria: { [key: string]: any } = {}
for (const key of primaryKeys) {
criteria[key] = compositeKeyValue[key]
}
return criteria
}),
}
} else {
deletionCriteria = { [primaryKeys[0]]: { $in: primaryKeyValues } }
}
await manager.nativeDelete<T>(entity as EntityName<T>, deletionCriteria)
await manager.nativeDelete<T>(entity as EntityName<T>, filters as any)
}
async find(options?: DAL.FindOptions<T>, context?: Context): Promise<T[]> {
@@ -423,11 +333,7 @@ export function mikroOrmBaseRepositoryFactory<
)
}
async upsert(
data: (TDTOs["create"] | TDTOs["update"])[],
context: Context = {}
): Promise<T[]> {
// TODO: Move this logic to the service packages/utils/src/modules-sdk/abstract-service-factory.ts
async upsert(data: any[], context: Context = {}): Promise<T[]> {
const manager = this.getActiveManager<EntityManager>(context)
const primaryKeys =
@@ -435,21 +341,34 @@ export function mikroOrmBaseRepositoryFactory<
let primaryKeysCriteria: { [key: string]: any }[] = []
if (primaryKeys.length === 1) {
primaryKeysCriteria.push({
[primaryKeys[0]]: data.map((d) => d[primaryKeys[0]]),
})
const primaryKeyValues = data
.map((d) => d[primaryKeys[0]])
.filter(Boolean)
if (primaryKeyValues.length) {
primaryKeysCriteria.push({
[primaryKeys[0]]: primaryKeyValues,
})
}
} else {
primaryKeysCriteria = data.map((d) => ({
$and: primaryKeys.map((key) => ({ [key]: d[key] })),
}))
}
const allEntities = await Promise.all(
primaryKeysCriteria.map(
async (criteria) =>
await this.find({ where: criteria } as DAL.FindOptions<T>, context)
let allEntities: T[][] = []
if (primaryKeysCriteria.length) {
allEntities = await Promise.all(
primaryKeysCriteria.map(
async (criteria) =>
await this.find(
{ where: criteria } as DAL.FindOptions<T>,
context
)
)
)
)
}
const existingEntities = allEntities.flat()
@@ -482,7 +401,7 @@ export function mikroOrmBaseRepositoryFactory<
const updatedType = manager.assign(existingEntity, data_)
updatedEntities.push(updatedType)
} else {
const newEntity = manager.create(entity, data_)
const newEntity = manager.create<T>(entity, data_)
createdEntities.push(newEntity)
}
})
@@ -497,9 +416,12 @@ export function mikroOrmBaseRepositoryFactory<
upsertedEntities.push(...updatedEntities)
}
// TODO return the all, created, updated entities
return upsertedEntities
}
}
return MikroOrmAbstractBaseRepository_
return MikroOrmAbstractBaseRepository_ as unknown as {
new ({ manager }: { manager: any }): MikroOrmBaseRepository<T>
}
}

View File

@@ -1,3 +1,5 @@
import { buildQuery } from "../../modules-sdk"
export const mikroOrmUpdateDeletedAtRecursively = async <
T extends object = any
>(
@@ -27,12 +29,35 @@ export const mikroOrmUpdateDeletedAtRecursively = async <
continue
}
const retrieveEntity = async () => {
const query = buildQuery(
{
id: entity.id,
},
{
relations: [relation.name],
withDeleted: true,
}
)
return await manager.findOne(
entity.constructor.name,
query.where,
query.options
)
}
if (!entityRelation) {
// Fixes the case of many to many through pivot table
entityRelation = await retrieveEntity()
}
const isCollection = "toArray" in entityRelation
let relationEntities: any[] = []
if (isCollection) {
if (!entityRelation.isInitialized()) {
entityRelation = await entityRelation.init({ populate: true })
entityRelation = await retrieveEntity()
entityRelation = entityRelation[relation.name]
}
relationEntities = entityRelation.getItems()
} else {
@@ -53,6 +78,10 @@ export const mikroOrmSerializer = async <TOutput extends object>(
): Promise<TOutput> => {
options ??= {}
const { serialize } = await import("@mikro-orm/core")
const result = serialize(data, options)
const result = serialize(data, {
forceObject: true,
populate: true,
...options,
})
return result as unknown as Promise<TOutput>
}

View File

@@ -1,104 +0,0 @@
import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types"
import { MedusaContext } from "../modules-sdk"
import { transactionWrapper } from "./utils"
class AbstractBase<T = any> {
protected readonly manager_: any
protected constructor({ manager }) {
this.manager_ = manager
}
getActiveManager<TManager = unknown>(
@MedusaContext()
{ transactionManager, manager }: Context = {}
): TManager {
return (transactionManager ?? manager ?? this.manager_) as TManager
}
async transaction<TManager = unknown>(
task: (transactionManager: TManager) => Promise<any>,
{
transaction,
isolationLevel,
enableNestedTransactions = false,
}: {
isolationLevel?: string
enableNestedTransactions?: boolean
transaction?: TManager
} = {}
): Promise<any> {
// @ts-ignore
return await transactionWrapper.apply(this, arguments)
}
}
export abstract class AbstractBaseRepository<T = any>
extends AbstractBase
implements DAL.RepositoryService<T>
{
abstract find(options?: DAL.FindOptions<T>, context?: Context)
abstract findAndCount(
options?: DAL.FindOptions<T>,
context?: Context
): Promise<[T[], number]>
abstract create(data: unknown[], context?: Context): Promise<T[]>
abstract update(data: unknown[], context?: Context): Promise<T[]>
abstract delete(ids: string[], context?: Context): Promise<void>
abstract upsert(data: unknown[], context?: Context): Promise<T[]>
abstract softDelete(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
abstract restore(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
abstract getFreshManager<TManager = unknown>(): TManager
abstract serialize<TOutput extends object | object[]>(
data: any,
options?: any
): Promise<TOutput>
}
export abstract class AbstractTreeRepositoryBase<T = any>
extends AbstractBase<T>
implements DAL.TreeRepositoryService<T>
{
protected constructor({ manager }) {
// @ts-ignore
super(...arguments)
}
abstract find(
options?: DAL.FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
)
abstract findAndCount(
options?: DAL.FindOptions<T>,
transformOptions?: RepositoryTransformOptions,
context?: Context
): Promise<[T[], number]>
abstract create(data: unknown, context?: Context): Promise<T>
abstract delete(id: string, context?: Context): Promise<void>
abstract getFreshManager<TManager = unknown>(): TManager
abstract serialize<TOutput extends object | object[]>(
data: any,
options?: any
): Promise<TOutput>
}

View File

@@ -0,0 +1,201 @@
import { abstractModuleServiceFactory } from "../abstract-module-service-factory"
const baseRepoMock = {
serialize: jest.fn().mockImplementation((item) => item),
transaction: (task) => task("transactionManager"),
getFreshManager: jest.fn().mockReturnThis(),
}
const defaultContext = { __type: "MedusaContext", manager: baseRepoMock }
const defaultTransactionContext = {
__type: "MedusaContext",
transactionManager: "transactionManager",
}
describe("Abstract Module Service Factory", () => {
const containerMock = {
baseRepository: baseRepoMock,
mainModelMockRepository: baseRepoMock,
otherModelMock1Repository: baseRepoMock,
otherModelMock2Repository: baseRepoMock,
mainModelMockService: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
otherModelMock1Service: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
otherModelMock2Service: {
retrieve: jest.fn().mockResolvedValue({ id: "1", name: "Item" }),
list: jest.fn().mockResolvedValue([{ id: "1", name: "Item" }]),
delete: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn().mockResolvedValue([[], {}]),
restore: jest.fn().mockResolvedValue([[], {}]),
},
}
const mainModelMock = class MainModelMock {}
const otherModelMock1 = class OtherModelMock1 {}
const otherModelMock2 = class OtherModelMock2 {}
const abstractModuleService = abstractModuleServiceFactory<
any,
any,
{
OtherModelMock1: {
dto: any
singular: "OtherModelMock1"
plural: "OtherModelMock1s"
}
OtherModelMock2: {
dto: any
singular: "OtherModelMock2"
plural: "OtherModelMock2s"
}
}
>(
mainModelMock,
[
{
model: otherModelMock1,
plural: "otherModelMock1s",
singular: "otherModelMock1",
},
{
model: otherModelMock2,
plural: "otherModelMock2s",
singular: "otherModelMock2",
},
]
// Add more parameters as needed
)
describe("Main Model Methods", () => {
let instance
beforeEach(() => {
jest.clearAllMocks()
instance = new abstractModuleService(containerMock)
})
test("should have retrieve method", async () => {
const result = await instance.retrieve("1")
expect(result).toEqual({ id: "1", name: "Item" })
expect(containerMock.mainModelMockService.retrieve).toHaveBeenCalledWith(
"1",
undefined,
defaultContext
)
})
test("should have list method", async () => {
const result = await instance.list()
expect(result).toEqual([{ id: "1", name: "Item" }])
expect(containerMock.mainModelMockService.list).toHaveBeenCalledWith(
{},
{},
defaultContext
)
})
test("should have delete method", async () => {
await instance.delete("1")
expect(containerMock.mainModelMockService.delete).toHaveBeenCalledWith(
["1"],
defaultTransactionContext
)
})
test("should have softDelete method", async () => {
const result = await instance.softDelete("1")
expect(result).toEqual(undefined)
expect(
containerMock.mainModelMockService.softDelete
).toHaveBeenCalledWith(["1"], defaultTransactionContext)
})
test("should have restore method", async () => {
const result = await instance.restore("1")
expect(result).toEqual(undefined)
expect(containerMock.mainModelMockService.restore).toHaveBeenCalledWith(
["1"],
defaultTransactionContext
)
})
test("should have delete method with selector", async () => {
await instance.delete({ selector: { id: "1" } })
expect(containerMock.mainModelMockService.delete).toHaveBeenCalledWith(
[{ selector: { id: "1" } }],
defaultTransactionContext
)
})
})
describe("Other Models Methods", () => {
let instance
beforeEach(() => {
jest.clearAllMocks()
instance = new abstractModuleService(containerMock)
})
test("should have retrieve method for other models", async () => {
const result = await instance.retrieveOtherModelMock1("1")
expect(result).toEqual({ id: "1", name: "Item" })
expect(
containerMock.otherModelMock1Service.retrieve
).toHaveBeenCalledWith("1", undefined, defaultContext)
})
test("should have list method for other models", async () => {
const result = await instance.listOtherModelMock1s()
expect(result).toEqual([{ id: "1", name: "Item" }])
expect(containerMock.otherModelMock1Service.list).toHaveBeenCalledWith(
{},
{},
defaultContext
)
})
test("should have delete method for other models", async () => {
await instance.deleteOtherModelMock1s("1")
expect(containerMock.otherModelMock1Service.delete).toHaveBeenCalledWith(
["1"],
defaultTransactionContext
)
})
test("should have softDelete method for other models", async () => {
const result = await instance.softDeleteOtherModelMock1s("1")
expect(result).toEqual(undefined)
expect(
containerMock.otherModelMock1Service.softDelete
).toHaveBeenCalledWith(["1"], defaultTransactionContext)
})
test("should have restore method for other models", async () => {
const result = await instance.restoreOtherModelMock1s("1")
expect(result).toEqual(undefined)
expect(containerMock.otherModelMock1Service.restore).toHaveBeenCalledWith(
["1"],
defaultTransactionContext
)
})
test("should have delete method for other models with selector", async () => {
await instance.deleteOtherModelMock1s({ selector: { id: "1" } })
expect(containerMock.otherModelMock1Service.delete).toHaveBeenCalledWith(
[{ selector: { id: "1" } }],
defaultTransactionContext
)
})
})
})

View File

@@ -0,0 +1,240 @@
import { internalModuleServiceFactory } from "../internal-module-service-factory"
import { lowerCaseFirst } from "../../common"
const defaultContext = { __type: "MedusaContext" }
class Model {}
describe("Internal Module Service Factory", () => {
const modelRepositoryName = `${lowerCaseFirst(Model.name)}Repository`
const containerMock = {
[modelRepositoryName]: {
transaction: (task) => task(),
getFreshManager: jest.fn().mockReturnThis(),
find: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
restore: jest.fn(),
upsert: jest.fn(),
},
[`composite${Model.name}Repository`]: {
transaction: (task) => task(),
getFreshManager: jest.fn().mockReturnThis(),
find: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
restore: jest.fn(),
upsert: jest.fn(),
},
}
const internalModuleService = internalModuleServiceFactory<any>(Model)
describe("Internal Module Service Methods", () => {
let instance
beforeEach(() => {
jest.clearAllMocks()
instance = new internalModuleService(containerMock)
})
test("should throw model id undefined error on retrieve if id is not defined", async () => {
const err = await instance.retrieve().catch((e) => e)
expect(err.message).toBe("model - id must be defined")
})
test("should throw an error on retrieve if composite key values are not defined", async () => {
class CompositeModel {
id: string
name: string
static meta = { primaryKeys: ["id", "name"] }
}
const compositeInternalModuleService =
internalModuleServiceFactory<any>(CompositeModel)
const instance = new compositeInternalModuleService(containerMock)
const err = await instance.retrieve().catch((e) => e)
expect(err.message).toBe("compositeModel - id, name must be defined")
})
test("should throw NOT_FOUND error on retrieve if entity not found", async () => {
containerMock[modelRepositoryName].find.mockResolvedValueOnce([])
const err = await instance.retrieve("1").catch((e) => e)
expect(err.message).toBe("Model with id: 1 was not found")
})
test("should retrieve entity successfully", async () => {
const entity = { id: "1", name: "Item" }
containerMock[modelRepositoryName].find.mockResolvedValueOnce([entity])
const result = await instance.retrieve("1")
expect(result).toEqual(entity)
})
test("should retrieve entity successfully with composite key", async () => {
class CompositeModel {
id: string
name: string
static meta = { primaryKeys: ["id", "name"] }
}
const compositeInternalModuleService =
internalModuleServiceFactory<any>(CompositeModel)
const instance = new compositeInternalModuleService(containerMock)
const entity = { id: "1", name: "Item" }
containerMock[
`${lowerCaseFirst(CompositeModel.name)}Repository`
].find.mockResolvedValueOnce([entity])
const result = await instance.retrieve({ id: "1", name: "Item" })
expect(result).toEqual(entity)
})
test("should list entities successfully", async () => {
const entities = [
{ id: "1", name: "Item" },
{ id: "2", name: "Item2" },
]
containerMock[modelRepositoryName].find.mockResolvedValueOnce(entities)
const result = await instance.list()
expect(result).toEqual(entities)
})
test("should list and count entities successfully", async () => {
const entities = [
{ id: "1", name: "Item" },
{ id: "2", name: "Item2" },
]
const count = entities.length
containerMock[modelRepositoryName].findAndCount.mockResolvedValueOnce([
entities,
count,
])
const result = await instance.listAndCount()
expect(result).toEqual([entities, count])
})
test("should create entity successfully", async () => {
const entity = { id: "1", name: "Item" }
containerMock[modelRepositoryName].find.mockReturnValue([entity])
containerMock[modelRepositoryName].create.mockImplementation(
async (entity) => entity
)
const result = await instance.create(entity)
expect(result).toEqual(entity)
})
test("should create entities successfully", async () => {
const entities = [
{ id: "1", name: "Item" },
{ id: "2", name: "Item2" },
]
containerMock[modelRepositoryName].find.mockResolvedValueOnce([entities])
containerMock[modelRepositoryName].create.mockResolvedValueOnce(entities)
const result = await instance.create(entities)
expect(result).toEqual(entities)
})
test("should update entity successfully", async () => {
const updateData = { id: "1", name: "UpdatedItem" }
containerMock[modelRepositoryName].find.mockResolvedValueOnce([
updateData,
])
containerMock[modelRepositoryName].update.mockResolvedValueOnce([
updateData,
])
const result = await instance.update(updateData)
expect(result).toEqual([updateData])
})
test("should update entities successfully", async () => {
const updateData = { id: "1", name: "UpdatedItem" }
const entitiesToUpdate = [{ id: "1", name: "Item" }]
containerMock[modelRepositoryName].find.mockResolvedValueOnce(
entitiesToUpdate
)
containerMock[modelRepositoryName].update.mockResolvedValueOnce([
{ entity: entitiesToUpdate[0], update: updateData },
])
const result = await instance.update({ selector: {}, data: updateData })
expect(result).toEqual([
{ entity: entitiesToUpdate[0], update: updateData },
])
})
test("should delete entity successfully", async () => {
await instance.delete("1")
expect(containerMock[modelRepositoryName].delete).toHaveBeenCalledWith(
{
$or: [
{
id: "1",
},
],
},
defaultContext
)
})
test("should delete entities successfully", async () => {
const entitiesToDelete = [{ id: "1", name: "Item" }]
containerMock[modelRepositoryName].find.mockResolvedValueOnce(
entitiesToDelete
)
await instance.delete({ selector: {} })
expect(containerMock[modelRepositoryName].delete).toHaveBeenCalledWith(
{
$or: [
{
id: "1",
},
],
},
defaultContext
)
})
test("should soft delete entity successfully", async () => {
await instance.softDelete("1")
expect(
containerMock[modelRepositoryName].softDelete
).toHaveBeenCalledWith("1", defaultContext)
})
test("should restore entity successfully", async () => {
await instance.restore("1")
expect(containerMock[modelRepositoryName].restore).toHaveBeenCalledWith(
"1",
defaultContext
)
})
})
})

View File

@@ -0,0 +1,527 @@
/**
* Utility factory and interfaces for module service public facing API
*/
import {
Constructor,
Context,
FindConfig,
IEventBusModuleService,
Pluralize,
RepositoryService,
RestoreReturn,
SoftDeleteReturn,
} from "@medusajs/types"
import {
isString,
kebabCase,
lowerCaseFirst,
mapObjectTo,
MapToConfig,
pluralize,
upperCaseFirst,
} from "../common"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
} from "./decorators"
type BaseMethods =
| "retrieve"
| "list"
| "listAndCount"
| "delete"
| "softDelete"
| "restore"
const readMethods = ["retrieve", "list", "listAndCount"] as BaseMethods[]
const writeMethods = ["delete", "softDelete", "restore"] as BaseMethods[]
const methods: BaseMethods[] = [...readMethods, ...writeMethods]
type ModelsConfigTemplate = {
[ModelName: string]: { singular?: string; plural?: string; dto: object }
}
type ExtractSingularName<
T extends Record<any, any>,
K = keyof T
> = T[K] extends { singular?: string } ? T[K]["singular"] : K
type ExtractPluralName<T extends Record<any, any>, K = keyof T> = T[K] extends {
plural?: string
}
? T[K]["plural"]
: Pluralize<K & string>
type ModelConfiguration =
| Constructor<any>
| { singular?: string; plural?: string; model: Constructor<any> }
export interface AbstractModuleServiceBase<TContainer, TMainModelDTO> {
get __container__(): TContainer
retrieve(
id: string,
config?: FindConfig<any>,
sharedContext?: Context
): Promise<TMainModelDTO>
list(
filters?: any,
config?: FindConfig<any>,
sharedContext?: Context
): Promise<TMainModelDTO[]>
listAndCount(
filters?: any,
config?: FindConfig<any>,
sharedContext?: Context
): Promise<[TMainModelDTO[], number]>
delete(
primaryKeyValues: string | object | string[] | object[],
sharedContext?: Context
): Promise<void>
softDelete<TReturnableLinkableKeys extends string>(
primaryKeyValues: string | object | string[] | object[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restore<TReturnableLinkableKeys extends string>(
primaryKeyValues: string | object | string[] | object[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}
/**
* Multiple issues on typescript around mapped types function are open, so
* when overriding a method from the base class that is mapped dynamically from the
* other models, we will have to ignore the error (2425)
*
* see: https://github.com/microsoft/TypeScript/issues/48125
*/
export type AbstractModuleService<
TContainer,
TMainModelDTO,
TOtherModelNamesAndAssociatedDTO extends ModelsConfigTemplate
> = AbstractModuleServiceBase<TContainer, TMainModelDTO> & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `retrieve${ExtractSingularName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: (
id: string,
config?: FindConfig<any>,
sharedContext?: Context
) => Promise<TOtherModelNamesAndAssociatedDTO[K & string]["dto"]>
} & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `list${ExtractPluralName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: (
filters?: any,
config?: FindConfig<any>,
sharedContext?: Context
) => Promise<TOtherModelNamesAndAssociatedDTO[K & string]["dto"][]>
} & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `listAndCount${ExtractPluralName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: {
(filters?: any, config?: FindConfig<any>, sharedContext?: Context): Promise<
[TOtherModelNamesAndAssociatedDTO[K & string]["dto"][], number]
>
}
} & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `delete${ExtractPluralName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: {
(
primaryKeyValues: string | object | string[] | object[],
sharedContext?: Context
): Promise<void>
}
} & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `softDelete${ExtractPluralName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: {
<TReturnableLinkableKeys extends string>(
primaryKeyValues: string | object | string[] | object[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}
} & {
[K in keyof TOtherModelNamesAndAssociatedDTO as `restore${ExtractPluralName<
TOtherModelNamesAndAssociatedDTO,
K
> &
string}`]: {
<TReturnableLinkableKeys extends string>(
primaryKeyValues: string | object | string[] | object[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}
}
/**
* Factory function for creating an abstract module service
*
* @example
*
* const otherModels = new Set([
* Currency,
* MoneyAmount,
* PriceList,
* PriceListRule,
* PriceListRuleValue,
* PriceRule,
* PriceSetMoneyAmount,
* PriceSetMoneyAmountRules,
* PriceSetRuleType,
* RuleType,
* ])
*
* const AbstractModuleService = ModulesSdkUtils.abstractModuleServiceFactory<
* InjectedDependencies,
* PricingTypes.PriceSetDTO,
* // The configuration of each entity also accept singular/plural properties, if not provided then it is using english pluralization
* {
* Currency: { dto: PricingTypes.CurrencyDTO }
* MoneyAmount: { dto: PricingTypes.MoneyAmountDTO }
* PriceSetMoneyAmount: { dto: PricingTypes.PriceSetMoneyAmountDTO }
* PriceSetMoneyAmountRules: {
* dto: PricingTypes.PriceSetMoneyAmountRulesDTO
* }
* PriceRule: { dto: PricingTypes.PriceRuleDTO }
* RuleType: { dto: PricingTypes.RuleTypeDTO }
* PriceList: { dto: PricingTypes.PriceListDTO }
* PriceListRule: { dto: PricingTypes.PriceListRuleDTO }
* }
* >(PriceSet, [...otherModels], entityNameToLinkableKeysMap)
*
* @param mainModel
* @param otherModels
* @param entityNameToLinkableKeysMap
*/
export function abstractModuleServiceFactory<
TContainer,
TMainModelDTO,
TOtherModelNamesAndAssociatedDTO extends ModelsConfigTemplate
>(
mainModel: Constructor<any>,
otherModels: ModelConfiguration[],
entityNameToLinkableKeysMap: MapToConfig = {}
): {
new (container: TContainer): AbstractModuleService<
TContainer,
TMainModelDTO,
TOtherModelNamesAndAssociatedDTO
>
} {
const buildMethodNamesFromModel = (
model: ModelConfiguration,
suffixed: boolean = true
): Record<string, string> => {
return methods.reduce((acc, method) => {
let modelName: string = ""
if (method === "retrieve") {
modelName =
"singular" in model && model.singular
? model.singular
: (model as Constructor<any>).name
} else {
modelName =
"plural" in model && model.plural
? model.plural
: pluralize((model as Constructor<any>).name)
}
const methodName = suffixed
? `${method}${upperCaseFirst(modelName)}`
: method
return { ...acc, [method]: methodName }
}, {})
}
const buildAndAssignMethodImpl = function (
klassPrototype: any,
method: string,
methodName: string,
model: Constructor<any>
): void {
const serviceRegistrationName = `${lowerCaseFirst(model.name)}Service`
const applyMethod = function (impl: Function, contextIndex) {
klassPrototype[methodName] = impl
const descriptorMockRef = {
value: klassPrototype[methodName],
}
MedusaContext()(klassPrototype, methodName, contextIndex)
const ManagerDecorator = readMethods.includes(method as BaseMethods)
? InjectManager
: InjectTransactionManager
ManagerDecorator("baseRepository_")(
klassPrototype,
methodName,
descriptorMockRef
)
klassPrototype[methodName] = descriptorMockRef.value
}
let methodImplementation: any = function () {
void 0
}
switch (method) {
case "retrieve":
methodImplementation = async function <T extends object>(
this: AbstractModuleService_,
id: string,
config?: FindConfig<any>,
sharedContext: Context = {}
): Promise<T> {
const entities = await this.__container__[
serviceRegistrationName
].retrieve(id, config, sharedContext)
return await this.baseRepository_.serialize<T>(entities, {
populate: true,
})
}
applyMethod(methodImplementation, 2)
break
case "list":
methodImplementation = async function <T extends object>(
this: AbstractModuleService_,
filters = {},
config: FindConfig<any> = {},
sharedContext: Context = {}
): Promise<T[]> {
const entities = await this.__container__[
serviceRegistrationName
].list(filters, config, sharedContext)
return await this.baseRepository_.serialize<T[]>(entities, {
populate: true,
})
}
applyMethod(methodImplementation, 2)
break
case "listAndCount":
methodImplementation = async function <T extends object>(
this: AbstractModuleService_,
filters = {},
config: FindConfig<any> = {},
sharedContext: Context = {}
): Promise<T[]> {
const [entities, count] = await this.__container__[
serviceRegistrationName
].listAndCount(filters, config, sharedContext)
return [
await this.baseRepository_.serialize<T[]>(entities, {
populate: true,
}),
count,
]
}
applyMethod(methodImplementation, 2)
break
case "delete":
methodImplementation = async function (
this: AbstractModuleService_,
primaryKeyValues: string | object | string[] | object[],
sharedContext: Context = {}
): Promise<void> {
const primaryKeyValues_ = Array.isArray(primaryKeyValues)
? primaryKeyValues
: [primaryKeyValues]
await this.__container__[serviceRegistrationName].delete(
primaryKeyValues_,
sharedContext
)
await this.eventBusModuleService_?.emit(
primaryKeyValues_.map((primaryKeyValue) => ({
eventName: `${kebabCase(model.name)}.deleted`,
data: isString(primaryKeyValue)
? { id: primaryKeyValue }
: primaryKeyValue,
}))
)
}
applyMethod(methodImplementation, 1)
break
case "softDelete":
methodImplementation = async function <T extends { id: string }>(
this: AbstractModuleService_,
primaryKeyValues: string | object | string[] | object[],
config: SoftDeleteReturn<string> = {},
sharedContext: Context = {}
): Promise<Record<string, string[]> | void> {
const primaryKeyValues_ = Array.isArray(primaryKeyValues)
? primaryKeyValues
: [primaryKeyValues]
const [entities, cascadedEntitiesMap] = await this.__container__[
serviceRegistrationName
].softDelete(primaryKeyValues_, sharedContext)
const softDeletedEntities = await this.baseRepository_.serialize<T[]>(
entities,
{
populate: true,
}
)
await this.eventBusModuleService_?.emit(
softDeletedEntities.map(({ id }) => ({
eventName: `${kebabCase(model.name)}.deleted`,
data: { id },
}))
)
let mappedCascadedEntitiesMap
if (config.returnLinkableKeys) {
// Map internal table/column names to their respective external linkable keys
// eg: product.id = product_id, variant.id = variant_id
mappedCascadedEntitiesMap = mapObjectTo(
cascadedEntitiesMap,
entityNameToLinkableKeysMap,
{
pick: config.returnLinkableKeys,
}
)
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
applyMethod(methodImplementation, 2)
break
case "restore":
methodImplementation = async function <T extends object>(
this: AbstractModuleService_,
primaryKeyValues: string | object | string[] | object[],
config: RestoreReturn<string> = {},
sharedContext: Context = {}
): Promise<Record<string, string[]> | void> {
const primaryKeyValues_ = Array.isArray(primaryKeyValues)
? primaryKeyValues
: [primaryKeyValues]
const [_, cascadedEntitiesMap] = await this.__container__[
serviceRegistrationName
].restore(primaryKeyValues_, sharedContext)
let mappedCascadedEntitiesMap
if (config.returnLinkableKeys) {
// Map internal table/column names to their respective external linkable keys
// eg: product.id = product_id, variant.id = variant_id
mappedCascadedEntitiesMap = mapObjectTo(
cascadedEntitiesMap,
entityNameToLinkableKeysMap,
{
pick: config.returnLinkableKeys,
}
)
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
applyMethod(methodImplementation, 2)
break
}
}
class AbstractModuleService_ {
readonly __container__: Record<string, any>
readonly baseRepository_: RepositoryService
readonly eventBusModuleService_: IEventBusModuleService;
[key: string]: any
constructor(container: Record<string, any>) {
this.__container__ = container
this.baseRepository_ = container.baseRepository
try {
this.eventBusModuleService_ = container.eventBusModuleService
} catch {
/* ignore */
}
}
}
const mainModelMethods = buildMethodNamesFromModel(mainModel, false)
/**
* Build the main retrieve/list/listAndCount/delete/softDelete/restore methods for the main model
*/
for (let [method, methodName] of Object.entries(mainModelMethods)) {
buildAndAssignMethodImpl(
AbstractModuleService_.prototype,
method,
methodName,
mainModel
)
}
/**
* Build the retrieve/list/listAndCount/delete/softDelete/restore methods for all the other models
*/
const otherModelsMethods: [ModelConfiguration, Record<string, string>][] =
otherModels.map((model) => [model, buildMethodNamesFromModel(model)])
for (let [model, modelsMethods] of otherModelsMethods) {
Object.entries(modelsMethods).forEach(([method, methodName]) => {
model = "model" in model ? model.model : model
buildAndAssignMethodImpl(
AbstractModuleService_.prototype,
method,
methodName,
model
)
})
}
return AbstractModuleService_ as unknown as new (
container: TContainer
) => AbstractModuleService<
TContainer,
TMainModelDTO,
TOtherModelNamesAndAssociatedDTO
>
}

View File

@@ -1,257 +0,0 @@
import {
Context,
FindConfig,
FilterQuery as InternalFilterQuery,
} from "@medusajs/types"
import { EntitySchema } from "@mikro-orm/core"
import { EntityClass } from "@mikro-orm/core/typings"
import {
MedusaError,
doNotForceTransaction,
isDefined,
isString,
lowerCaseFirst,
shouldForceTransaction,
upperCaseFirst,
} from "../common"
import { MedusaContext } from "../modules-sdk"
import { buildQuery } from "./build-query"
import { InjectManager, InjectTransactionManager } from "./decorators"
/**
* Utility factory and interfaces for internal module services
*/
type FilterableMethods = "list" | "listAndCount"
type Methods = "create" | "update"
export interface AbstractService<
TEntity extends {},
TContainer extends object = object,
TDTOs extends { [K in Methods]?: any } = { [K in Methods]?: any },
TFilters extends { [K in FilterableMethods]?: any } = {
[K in FilterableMethods]?: any
}
> {
get __container__(): TContainer
retrieve<TEntityMethod = TEntity>(
id: string,
config?: FindConfig<TEntityMethod>,
sharedContext?: Context
): Promise<TEntity>
list<TEntityMethod = TEntity>(
filters?: TFilters["list"],
config?: FindConfig<TEntityMethod>,
sharedContext?: Context
): Promise<TEntity[]>
listAndCount<TEntityMethod = TEntity>(
filters?: TFilters["listAndCount"],
config?: FindConfig<TEntityMethod>,
sharedContext?: Context
): Promise<[TEntity[], number]>
create(data: TDTOs["create"][], sharedContext?: Context): Promise<TEntity[]>
update(data: TDTOs["update"][], sharedContext?: Context): Promise<TEntity[]>
delete(
primaryKeyValues: string[] | object[],
sharedContext?: Context
): Promise<void>
softDelete(
idsOrFilter: string[] | InternalFilterQuery,
sharedContext?: Context
): Promise<[TEntity[], Record<string, unknown[]>]>
restore(
idsOrFilter: string[] | InternalFilterQuery,
sharedContext?: Context
): Promise<[TEntity[], Record<string, unknown[]>]>
upsert(
data: (TDTOs["create"] | TDTOs["update"])[],
sharedContext?: Context
): Promise<TEntity[]>
}
export function abstractServiceFactory<
TContainer extends object = object,
TDTOs extends { [K in Methods]?: any } = { [K in Methods]?: any },
TFilters extends { [K in FilterableMethods]?: any } = {
[K in FilterableMethods]?: any
}
>(
model: new (...args: any[]) => any
): {
new <TEntity extends object = any>(container: TContainer): AbstractService<
TEntity,
TContainer,
TDTOs,
TFilters
>
} {
const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository`
const propertyRepositoryName = `__${injectedRepositoryName}__`
class AbstractService_<TEntity extends {}>
implements AbstractService<TEntity, TContainer, TDTOs, TFilters>
{
readonly __container__: TContainer;
[key: string]: any
constructor(container: TContainer) {
this.__container__ = container
this[propertyRepositoryName] = container[injectedRepositoryName]
}
static retrievePrimaryKeys(entity: EntityClass<any> | EntitySchema<any>) {
return (
(entity as EntitySchema<any>).meta?.primaryKeys ??
(entity as EntityClass<any>).prototype.__meta?.primaryKeys ?? ["id"]
)
}
@InjectManager(propertyRepositoryName)
async retrieve<TEntityMethod = TEntity>(
primaryKeyValues: string | string[] | object[],
config: FindConfig<TEntityMethod> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity> {
const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
if (!isDefined(primaryKeyValues)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${
primaryKeys.length === 1
? `"${
lowerCaseFirst(model.name) + upperCaseFirst(primaryKeys[0])
}"`
: `${lowerCaseFirst(model.name)} ${primaryKeys.join(", ")}`
} must be defined`
)
}
let primaryKeysCriteria = {}
if (primaryKeys.length === 1) {
primaryKeysCriteria[primaryKeys[0]] = primaryKeyValues
} else {
primaryKeysCriteria = (primaryKeyValues as string[] | object[]).map(
(primaryKeyValue) => ({
$and: primaryKeys.map((key) => ({ [key]: primaryKeyValue[key] })),
})
)
}
const queryOptions = buildQuery<TEntity>(primaryKeysCriteria, config)
const entities = await this[propertyRepositoryName].find(
queryOptions,
sharedContext
)
if (!entities?.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${model.name} with ${primaryKeys.join(", ")}: ${
Array.isArray(primaryKeyValues)
? primaryKeyValues.map((v) =>
[isString(v) ? v : Object.values(v)].join(", ")
)
: primaryKeyValues
} was not found`
)
}
return entities[0]
}
@InjectManager(propertyRepositoryName)
async list<TEntityMethod = TEntity>(
filters: TFilters["list"] = {},
config: FindConfig<TEntityMethod> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const queryOptions = buildQuery<TEntity>(filters, config)
return (await this[propertyRepositoryName].find(
queryOptions,
sharedContext
)) as TEntity[]
}
@InjectManager(propertyRepositoryName)
async listAndCount<TEntityMethod = TEntity>(
filters: TFilters["listAndCount"] = {},
config: FindConfig<TEntityMethod> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
const queryOptions = buildQuery<TEntity>(filters, config)
return (await this[propertyRepositoryName].findAndCount(
queryOptions,
sharedContext
)) as [TEntity[], number]
}
@InjectTransactionManager(shouldForceTransaction, propertyRepositoryName)
async create(
data: TDTOs["create"][],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await this[propertyRepositoryName].create(
data,
sharedContext
)) as TEntity[]
}
@InjectTransactionManager(shouldForceTransaction, propertyRepositoryName)
async update(
data: TDTOs["update"][],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await this[propertyRepositoryName].update(
data,
sharedContext
)) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, propertyRepositoryName)
async delete(
primaryKeyValues: string[] | object[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this[propertyRepositoryName].delete(primaryKeyValues, sharedContext)
}
@InjectTransactionManager(propertyRepositoryName)
async softDelete(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this[propertyRepositoryName].softDelete(
idsOrFilter,
sharedContext
)
}
@InjectTransactionManager(propertyRepositoryName)
async restore(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this[propertyRepositoryName].restore(
idsOrFilter,
sharedContext
)
}
@InjectTransactionManager(propertyRepositoryName)
async upsert(
data: (TDTOs["create"] | TDTOs["update"])[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this[propertyRepositoryName].upsert(data, sharedContext)
}
}
return AbstractService_ as unknown as new <TEntity extends {}>(
container: TContainer
) => AbstractService<TEntity, TContainer, TDTOs, TFilters>
}

View File

@@ -1,4 +1,5 @@
import { Context } from "@medusajs/types"
import { MedusaContextType } from "./context-parameter"
export function InjectManager(managerProperty?: string): MethodDecorator {
return function (
@@ -37,8 +38,14 @@ export function InjectManager(managerProperty?: string): MethodDecorator {
? this
: this[managerProperty]
copiedContext.manager ??= resourceWithManager.getFreshManager()
copiedContext.transactionManager ??= originalContext?.transactionManager
copiedContext.manager =
originalContext.manager ?? resourceWithManager.getFreshManager()
if (originalContext?.transactionManager) {
copiedContext.transactionManager = originalContext?.transactionManager
}
copiedContext.__type = MedusaContextType
args[argIndex] = copiedContext

View File

@@ -60,9 +60,13 @@ export function InjectTransactionManager(
})
}
copiedContext.transactionManager ??= transactionManager
copiedContext.manager ??= originalContext?.manager
copiedContext.__type ??= MedusaContextType
copiedContext.transactionManager = transactionManager
if (originalContext?.manager) {
copiedContext.manager = originalContext?.manager
}
copiedContext.__type = MedusaContextType
args[argIndex] = copiedContext

View File

@@ -5,4 +5,5 @@ export * from "./loaders/mikro-orm-connection-loader"
export * from "./loaders/container-loader-factory"
export * from "./create-pg-connection"
export * from "./migration-scripts"
export * from "./abstract-service-factory"
export * from "./internal-module-service-factory"
export * from "./abstract-module-service-factory"

View File

@@ -0,0 +1,442 @@
import {
BaseFilterable,
Context,
FilterQuery,
FilterQuery as InternalFilterQuery,
FindConfig,
ModulesSdkTypes,
} from "@medusajs/types"
import { EntitySchema } from "@mikro-orm/core"
import { EntityClass } from "@mikro-orm/core/typings"
import {
doNotForceTransaction,
isDefined,
isObject,
isString,
lowerCaseFirst,
MedusaError,
shouldForceTransaction,
} from "../common"
import { buildQuery } from "./build-query"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
} from "./decorators"
type SelectorAndData = {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
data: any
}
export function internalModuleServiceFactory<
TContainer extends object = object
>(
model: any
): {
new <TEntity extends object = any>(
container: TContainer
): ModulesSdkTypes.InternalModuleService<TEntity, TContainer>
} {
const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository`
const propertyRepositoryName = `__${injectedRepositoryName}__`
class AbstractService_<TEntity extends object>
implements ModulesSdkTypes.InternalModuleService<TEntity, TContainer>
{
readonly __container__: TContainer;
[key: string]: any
constructor(container: TContainer) {
this.__container__ = container
this[propertyRepositoryName] = container[injectedRepositoryName]
}
static retrievePrimaryKeys(entity: EntityClass<any> | EntitySchema<any>) {
return (
(entity as EntitySchema<any>).meta?.primaryKeys ??
(entity as EntityClass<any>).prototype.__meta?.primaryKeys ?? ["id"]
)
}
static buildUniqueCompositeKeyValue(keys: string[], data: object) {
return keys.map((k) => data[k]).join("_")
}
@InjectManager(propertyRepositoryName)
async retrieve(
idOrObject: string | object,
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity> {
const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
if (
!isDefined(idOrObject) ||
(isString(idOrObject) && primaryKeys.length > 1) ||
((!isString(idOrObject) ||
(isObject(idOrObject) && !idOrObject[primaryKeys[0]])) &&
primaryKeys.length === 1)
) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${
primaryKeys.length === 1
? `${lowerCaseFirst(model.name) + " - " + primaryKeys[0]}`
: `${lowerCaseFirst(model.name)} - ${primaryKeys.join(", ")}`
} must be defined`
)
}
let primaryKeysCriteria = {}
if (primaryKeys.length === 1) {
primaryKeysCriteria[primaryKeys[0]] = idOrObject
} else {
const idOrObject_ = Array.isArray(idOrObject)
? idOrObject
: [idOrObject]
primaryKeysCriteria = idOrObject_.map((primaryKeyValue) => ({
$and: primaryKeys.map((key) => ({ [key]: primaryKeyValue[key] })),
}))
}
const queryOptions = buildQuery(primaryKeysCriteria, config)
const entities = await this[propertyRepositoryName].find(
queryOptions,
sharedContext
)
if (!entities?.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${model.name} with ${primaryKeys.join(", ")}: ${
Array.isArray(idOrObject)
? idOrObject.map((v) =>
[isString(v) ? v : Object.values(v)].join(", ")
)
: idOrObject
} was not found`
)
}
return entities[0]
}
@InjectManager(propertyRepositoryName)
async list(
filters: FilterQuery<any> | BaseFilterable<FilterQuery<any>> = {},
config: FindConfig<any> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const queryOptions = buildQuery(filters, config)
return await this[propertyRepositoryName].find(
queryOptions,
sharedContext
)
}
@InjectManager(propertyRepositoryName)
async listAndCount(
filters: FilterQuery<any> | BaseFilterable<FilterQuery<any>> = {},
config: FindConfig<any> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
const queryOptions = buildQuery(filters, config)
return await this[propertyRepositoryName].findAndCount(
queryOptions,
sharedContext
)
}
create(data: any, sharedContext?: Context): Promise<TEntity>
create(data: any[], sharedContext?: Context): Promise<TEntity[]>
@InjectTransactionManager(shouldForceTransaction, propertyRepositoryName)
async create(
data: any | any[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity | TEntity[]> {
if (!isDefined(data) || (Array.isArray(data) && data.length === 0)) {
return (Array.isArray(data) ? [] : void 0) as TEntity | TEntity[]
}
const data_ = Array.isArray(data) ? data : [data]
const entities = await this[propertyRepositoryName].create(
data_,
sharedContext
)
return Array.isArray(data) ? entities : entities[0]
}
update(data: any[], sharedContext?: Context): Promise<TEntity[]>
update(data: any, sharedContext?: Context): Promise<TEntity>
update(
selectorAndData: SelectorAndData,
sharedContext?: Context
): Promise<TEntity[]>
update(
selectorAndData: SelectorAndData[],
sharedContext?: Context
): Promise<TEntity[]>
@InjectTransactionManager(shouldForceTransaction, propertyRepositoryName)
async update(
input: any | any[] | SelectorAndData | SelectorAndData[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity | TEntity[]> {
if (!isDefined(input) || (Array.isArray(input) && input.length === 0)) {
return (Array.isArray(input) ? [] : void 0) as TEntity | TEntity[]
}
const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
const inputArray = Array.isArray(input) ? input : [input]
const toUpdateData: { entity; update }[] = []
// Only used when we receive data and no selector
const keySelectorForDataOnly: any = {
$or: [],
}
const keySelectorDataMap = new Map<string, any>()
for (const input_ of inputArray) {
if (input_.selector) {
const entitiesToUpdate = await this.list(
input_.selector,
{},
sharedContext
)
// Create a pair of entity and data to update
entitiesToUpdate.forEach((entity) => {
toUpdateData.push({
entity,
update: input_.data,
})
})
} else {
// in case we are manipulating the data, then extract the primary keys as a selector and the rest as the data to update
const selector = {}
primaryKeys.forEach((key) => {
selector[key] = input_[key]
})
const uniqueCompositeKey =
AbstractService_.buildUniqueCompositeKeyValue(primaryKeys, input_)
keySelectorDataMap.set(uniqueCompositeKey, input_)
keySelectorForDataOnly.$or.push(selector)
}
}
if (keySelectorForDataOnly.$or.length) {
const entitiesToUpdate = await this.list(
keySelectorForDataOnly,
{},
sharedContext
)
// Create a pair of entity and data to update
entitiesToUpdate.forEach((entity) => {
const uniqueCompositeKey =
AbstractService_.buildUniqueCompositeKeyValue(primaryKeys, entity)
toUpdateData.push({
entity,
update: keySelectorDataMap.get(uniqueCompositeKey)!,
})
})
// Only throw for missing entities when we dont have selectors involved as selector by design can return 0 entities
if (entitiesToUpdate.length !== keySelectorDataMap.size) {
const entityName = (model as EntityClass<TEntity>).name ?? model
const compositeKeysValuesForFoundEntities = new Set(
entitiesToUpdate.map((entity) => {
return AbstractService_.buildUniqueCompositeKeyValue(
primaryKeys,
entity
)
})
)
const missingEntityValues: any[] = []
;[...keySelectorDataMap.keys()].filter((key) => {
if (!compositeKeysValuesForFoundEntities.has(key)) {
const value = key.replace(/_/gi, " - ")
missingEntityValues.push(value)
}
})
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${entityName} with ${primaryKeys.join(
", "
)} "${missingEntityValues.join(", ")}" not found`
)
}
}
return await this[propertyRepositoryName].update(
toUpdateData,
sharedContext
)
}
delete(idOrSelector: string, sharedContext?: Context): Promise<void>
delete(idOrSelector: string[], sharedContext?: Context): Promise<void>
delete(idOrSelector: object, sharedContext?: Context): Promise<void>
delete(idOrSelector: object[], sharedContext?: Context): Promise<void>
delete(
idOrSelector: {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
},
sharedContext?: Context
): Promise<void>
@InjectTransactionManager(doNotForceTransaction, propertyRepositoryName)
async delete(
idOrSelector:
| string
| string[]
| object
| object[]
| {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
},
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
if (
!isDefined(idOrSelector) ||
(Array.isArray(idOrSelector) && idOrSelector.length === 0)
) {
return
}
const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
if (
(Array.isArray(idOrSelector) && idOrSelector.length === 0) ||
((isString(idOrSelector) ||
(Array.isArray(idOrSelector) && isString(idOrSelector[0]))) &&
primaryKeys.length > 1)
) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`${
primaryKeys.length === 1
? `"${lowerCaseFirst(model.name) + " - " + primaryKeys[0]}"`
: `${lowerCaseFirst(model.name)} - ${primaryKeys.join(", ")}`
} must be defined`
)
}
const deleteCriteria: any = {
$or: [],
}
if (isObject(idOrSelector) && "selector" in idOrSelector) {
const entitiesToDelete = await this.list(
idOrSelector.selector as FilterQuery<any>,
{
select: primaryKeys,
},
sharedContext
)
for (const entity of entitiesToDelete) {
const criteria = {}
primaryKeys.forEach((key) => {
criteria[key] = entity[key]
})
deleteCriteria.$or.push(criteria)
}
} else {
const primaryKeysValues = Array.isArray(idOrSelector)
? idOrSelector
: [idOrSelector]
deleteCriteria.$or = primaryKeysValues.map((primaryKeyValue) => {
const criteria = {}
if (isObject(primaryKeyValue)) {
Object.entries(primaryKeyValue).forEach(([key, value]) => {
criteria[key] = value
})
} else {
criteria[primaryKeys[0]] = primaryKeyValue
}
// TODO: Revisit
/*primaryKeys.forEach((key) => {
/!*if (
isObject(primaryKeyValue) &&
!isDefined(primaryKeyValue[key]) &&
// primaryKeys.length > 1
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Composite key must contain all primary key fields: ${primaryKeys.join(
", "
)}. Found: ${Object.keys(primaryKeyValue)}`
)
}*!/
criteria[key] = isObject(primaryKeyValue)
? primaryKeyValue[key]
: primaryKeyValue
})*/
return criteria
})
}
await this[propertyRepositoryName].delete(deleteCriteria, sharedContext)
}
@InjectTransactionManager(propertyRepositoryName)
async softDelete(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this[propertyRepositoryName].softDelete(
idsOrFilter,
sharedContext
)
}
@InjectTransactionManager(propertyRepositoryName)
async restore(
idsOrFilter: string[] | InternalFilterQuery,
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this[propertyRepositoryName].restore(
idsOrFilter,
sharedContext
)
}
upsert(data: any[], sharedContext?: Context): Promise<TEntity[]>
upsert(data: any, sharedContext?: Context): Promise<TEntity>
@InjectTransactionManager(propertyRepositoryName)
async upsert(
data: any | any[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity | TEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
const entities = await this[propertyRepositoryName].upsert(
data_,
sharedContext
)
return Array.isArray(data) ? entities : entities[0]
}
}
return AbstractService_ as unknown as new <TEntity extends {}>(
container: TContainer
) => ModulesSdkTypes.InternalModuleService<TEntity, TContainer>
}

View File

@@ -8,7 +8,7 @@ import {
} from "@medusajs/types"
import { lowerCaseFirst } from "../../common"
import { asClass } from "awilix"
import { abstractServiceFactory } from "../abstract-service-factory"
import { internalModuleServiceFactory } from "../internal-module-service-factory"
import { mikroOrmBaseRepositoryFactory } from "../../dal"
type RepositoryLoaderOptions = {
@@ -96,7 +96,7 @@ export function loadModuleServices({
const finalService = moduleServicesMap.get(mappedServiceName)
if (!finalService) {
moduleServicesMap.set(mappedServiceName, abstractServiceFactory(Model))
moduleServicesMap.set(mappedServiceName, internalModuleServiceFactory(Model))
}
})