Files
medusa-store/packages/utils/src/modules-sdk/internal-module-service-factory.ts
Stevche Radevski a6562d2a41 feat: Modify the abstract repository upsert to handle subresources as per convention (#6813)
* feat: Modify the abstract repository upsert method to handle subresources correctly

* fix: Preserve the upsertWithResponse order in the response, and return all the data

* fix: Create integration tests folder for mikro orm utils that run against the DB

* fix: Remove many-to-one creation and additional changes based on PR review
2024-03-28 15:28:57 +01:00

496 lines
15 KiB
TypeScript

import {
BaseFilterable,
Context,
FilterQuery,
FindConfig,
FilterQuery as InternalFilterQuery,
ModulesSdkTypes,
} from "@medusajs/types"
import { EntitySchema } from "@mikro-orm/core"
import { EntityClass } from "@mikro-orm/core/typings"
import {
MedusaError,
doNotForceTransaction,
isDefined,
isObject,
isString,
lowerCaseFirst,
shouldForceTransaction,
} from "../common"
import { buildQuery } from "./build-query"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
} from "./decorators"
import { UpsertWithReplaceConfig } from "@medusajs/types"
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(":")
}
/**
* Only apply top level default ordering as the relation
* default ordering is already applied through the foreign key
* @param config
*/
static applyDefaultOrdering(config: FindConfig<any>) {
if (config.order) {
return
}
config.order = {}
const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
primaryKeys.forEach((primaryKey) => {
config.order![primaryKey] = "ASC"
})
}
@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[]> {
AbstractService_.applyDefaultOrdering(config)
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]> {
AbstractService_.applyDefaultOrdering(config)
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)
) {
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[]>]> {
if (Array.isArray(idsOrFilter) && !idsOrFilter.length) {
return [[], {}]
}
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]
}
upsertWithReplace(
data: any[],
config?: UpsertWithReplaceConfig<TEntity>,
sharedContext?: Context
): Promise<TEntity[]>
upsertWithReplace(
data: any,
config?: UpsertWithReplaceConfig<TEntity>,
sharedContext?: Context
): Promise<TEntity>
@InjectTransactionManager(propertyRepositoryName)
async upsertWithReplace(
data: any | any[],
config: UpsertWithReplaceConfig<TEntity> = {
relations: [],
},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity | TEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
const entities = await this[propertyRepositoryName].upsertWithReplace(
data_,
config,
sharedContext
)
return Array.isArray(data) ? entities : entities[0]
}
}
return AbstractService_ as unknown as new <TEntity extends {}>(
container: TContainer
) => ModulesSdkTypes.InternalModuleService<TEntity, TContainer>
}