feat(link-modules,modules-sdk, utils, types, products) - Remote Link and Link modules (#4695)
What:
- Definition of all Modules links
- `link-modules` package to manage the creation of all pre-defined link or custom ones
```typescript
import { initialize as iniInventory } from "@medusajs/inventory";
import { initialize as iniProduct } from "@medusajs/product";
import {
initialize as iniLinks,
runMigrations as migrateLinks
} from "@medusajs/link-modules";
await Promise.all([iniInventory(), iniProduct()]);
await migrateLinks(); // create tables based on previous loaded modules
await iniLinks(); // load link based on previous loaded modules
await iniLinks(undefined, [
{
serviceName: "product_custom_translation_service_link",
isLink: true,
databaseConfig: {
tableName: "product_transalations",
},
alias: [
{
name: "translations",
},
],
primaryKeys: ["id", "product_id", "translation_id"],
relationships: [
{
serviceName: Modules.PRODUCT,
primaryKey: "id",
foreignKey: "product_id",
alias: "product",
},
{
serviceName: "custom_translation_service",
primaryKey: "id",
foreignKey: "translation_id",
alias: "transalation",
deleteCascade: true,
},
],
extends: [
{
serviceName: Modules.PRODUCT,
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "translations",
isList: true,
},
},
{
serviceName: "custom_translation_service",
relationship: {
serviceName: "product_custom_translation_service_link",
primaryKey: "product_id",
foreignKey: "id",
alias: "product_link",
},
},
],
},
]); // custom links
```
Remote Link
```typescript
import { RemoteLink, Modules } from "@medusajs/modules-sdk";
// [...] initialize modules and links
const remoteLink = new RemoteLink();
// upsert the relationship
await remoteLink.create({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
data: { // optional additional fields
required_quantity: 5
}
});
// dismiss (doesn't cascade)
await remoteLink.dismiss({ // one (object) or many (array)
[Modules.PRODUCT]: {
variant_id: "var_abc",
},
[Modules.INVENTORY]: {
inventory_item_id: "iitem_abc",
},
});
// delete
await remoteLink.delete({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
// restore
await remoteLink.restore({
// every key is a module
[Modules.PRODUCT]: {
// every key is a linkable field
variant_id: "var_abc", // single or multiple values
},
});
```
Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com>
This commit is contained in:
co-authored by
Riqwan Thamir
parent
bc4c9e0d32
commit
4d16acf5f0
@@ -0,0 +1,2 @@
|
||||
export * from "./inventory-level-stock-location"
|
||||
export * from "./product-variant-inventory-item"
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ModuleJoinerConfig } from "@medusajs/types"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
|
||||
export const InventoryLevelStockLocation: ModuleJoinerConfig = {
|
||||
isLink: true,
|
||||
isReadOnlyLink: true,
|
||||
extends: [
|
||||
{
|
||||
serviceName: Modules.INVENTORY,
|
||||
relationship: {
|
||||
serviceName: Modules.STOCK_LOCATION,
|
||||
primaryKey: "id",
|
||||
foreignKey: "location_id",
|
||||
alias: "stock_locations",
|
||||
isList: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
import { ModuleJoinerConfig } from "@medusajs/types"
|
||||
import { LINKS } from "../links"
|
||||
|
||||
export const ProductVariantInventoryItem: ModuleJoinerConfig = {
|
||||
serviceName: LINKS.ProductVariantInventoryItem,
|
||||
isLink: true,
|
||||
databaseConfig: {
|
||||
tableName: "product_variant_inventory_item",
|
||||
idPrefix: "pvitem",
|
||||
extraFields: {
|
||||
required_quantity: {
|
||||
type: "integer",
|
||||
defaultValue: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
alias: [
|
||||
{
|
||||
name: "product_variant_inventory_item",
|
||||
},
|
||||
{
|
||||
name: "product_variant_inventory_items",
|
||||
},
|
||||
],
|
||||
primaryKeys: ["id", "variant_id", "inventory_item_id"],
|
||||
relationships: [
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
primaryKey: "id",
|
||||
foreignKey: "variant_id",
|
||||
alias: "variant",
|
||||
args: {
|
||||
methodSuffix: "Variants",
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.INVENTORY,
|
||||
primaryKey: "id",
|
||||
foreignKey: "inventory_item_id",
|
||||
alias: "inventory",
|
||||
deleteCascade: true,
|
||||
},
|
||||
],
|
||||
extends: [
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
relationship: {
|
||||
serviceName: LINKS.ProductVariantInventoryItem,
|
||||
primaryKey: "variant_id",
|
||||
foreignKey: "id",
|
||||
alias: "inventory_items",
|
||||
isList: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.INVENTORY,
|
||||
relationship: {
|
||||
serviceName: LINKS.ProductVariantInventoryItem,
|
||||
primaryKey: "inventory_item_id",
|
||||
foreignKey: "id",
|
||||
alias: "variant_link",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./initialize"
|
||||
export * from "./types"
|
||||
export * from "./loaders"
|
||||
export * from "./services"
|
||||
@@ -0,0 +1,190 @@
|
||||
import { InternalModuleDeclaration, MedusaModule } from "@medusajs/modules-sdk"
|
||||
import {
|
||||
ExternalModuleDeclaration,
|
||||
ILinkModule,
|
||||
LinkModuleDefinition,
|
||||
LoaderOptions,
|
||||
MODULE_RESOURCE_TYPE,
|
||||
MODULE_SCOPE,
|
||||
ModuleExports,
|
||||
ModuleJoinerConfig,
|
||||
ModuleServiceInitializeCustomDataLayerOptions,
|
||||
ModuleServiceInitializeOptions,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
lowerCaseFirst,
|
||||
simpleHash,
|
||||
} from "@medusajs/utils"
|
||||
import * as linkDefinitions from "../definitions"
|
||||
import { getMigration } from "../migration"
|
||||
import { InitializeModuleInjectableDependencies } from "../types"
|
||||
import { composeLinkName } from "../utils"
|
||||
import { getLinkModuleDefinition } from "./module-definition"
|
||||
|
||||
export const initialize = async (
|
||||
options?:
|
||||
| ModuleServiceInitializeOptions
|
||||
| ModuleServiceInitializeCustomDataLayerOptions
|
||||
| ExternalModuleDeclaration
|
||||
| InternalModuleDeclaration,
|
||||
modulesDefinition?: ModuleJoinerConfig[],
|
||||
injectedDependencies?: InitializeModuleInjectableDependencies
|
||||
): Promise<{ [link: string]: ILinkModule }> => {
|
||||
const allLinks = {}
|
||||
const modulesLoadedKeys = MedusaModule.getLoadedModules().map(
|
||||
(mod) => Object.keys(mod)[0]
|
||||
)
|
||||
|
||||
const allLinksToLoad = Object.values(linkDefinitions).concat(
|
||||
modulesDefinition ?? []
|
||||
)
|
||||
|
||||
for (const linkDefinition of allLinksToLoad) {
|
||||
const definition = JSON.parse(JSON.stringify(linkDefinition))
|
||||
|
||||
const [primary, foreign] = definition.relationships ?? []
|
||||
|
||||
if (definition.relationships?.length !== 2 && !definition.isReadOnlyLink) {
|
||||
throw new Error(
|
||||
`Link module ${definition.serviceName} can only link 2 modules.`
|
||||
)
|
||||
} else if (
|
||||
foreign?.foreignKey?.split(",").length > 1 &&
|
||||
!definition.isReadOnlyLink
|
||||
) {
|
||||
throw new Error(`Foreign key cannot be a composed key.`)
|
||||
}
|
||||
|
||||
const serviceKey = !definition.isReadOnlyLink
|
||||
? lowerCaseFirst(
|
||||
definition.serviceName ??
|
||||
composeLinkName(
|
||||
primary.serviceName,
|
||||
primary.foreignKey,
|
||||
foreign.serviceName,
|
||||
foreign.foreignKey
|
||||
)
|
||||
)
|
||||
: simpleHash(JSON.stringify(definition.extends))
|
||||
|
||||
if (modulesLoadedKeys.includes(serviceKey)) {
|
||||
continue
|
||||
} else if (serviceKey in allLinks) {
|
||||
throw new Error(`Link module ${serviceKey} already defined.`)
|
||||
}
|
||||
|
||||
if (definition.isReadOnlyLink) {
|
||||
const extended: any[] = []
|
||||
for (const extension of definition.extends ?? []) {
|
||||
if (
|
||||
modulesLoadedKeys.includes(extension.serviceName) &&
|
||||
modulesLoadedKeys.includes(extension.relationship.serviceName)
|
||||
) {
|
||||
extended.push(extension)
|
||||
}
|
||||
}
|
||||
|
||||
definition.extends = extended
|
||||
if (extended.length === 0) {
|
||||
continue
|
||||
}
|
||||
} else if (
|
||||
!modulesLoadedKeys.includes(primary.serviceName) ||
|
||||
!modulesLoadedKeys.includes(foreign.serviceName)
|
||||
) {
|
||||
// TODO: This should be uncommented when all modules are done
|
||||
// continue
|
||||
}
|
||||
|
||||
const moduleDefinition = getLinkModuleDefinition(
|
||||
definition,
|
||||
primary,
|
||||
foreign
|
||||
) as ModuleExports
|
||||
|
||||
const linkModuleDefinition: LinkModuleDefinition = {
|
||||
key: serviceKey,
|
||||
registrationName: serviceKey,
|
||||
label: serviceKey,
|
||||
defaultModuleDeclaration: {
|
||||
scope: MODULE_SCOPE.INTERNAL,
|
||||
resources: injectedDependencies?.[
|
||||
ContainerRegistrationKeys.PG_CONNECTION
|
||||
]
|
||||
? MODULE_RESOURCE_TYPE.SHARED
|
||||
: MODULE_RESOURCE_TYPE.ISOLATED,
|
||||
},
|
||||
}
|
||||
|
||||
const loaded = await MedusaModule.bootstrapLink(
|
||||
linkModuleDefinition,
|
||||
options as InternalModuleDeclaration,
|
||||
moduleDefinition,
|
||||
injectedDependencies
|
||||
)
|
||||
|
||||
allLinks[serviceKey as string] = Object.values(loaded)[0]
|
||||
}
|
||||
|
||||
return allLinks
|
||||
}
|
||||
|
||||
export async function runMigrations(
|
||||
{
|
||||
options,
|
||||
logger,
|
||||
}: Omit<LoaderOptions<ModuleServiceInitializeOptions>, "container">,
|
||||
modulesDefinition?: ModuleJoinerConfig[]
|
||||
) {
|
||||
const modulesLoadedKeys = MedusaModule.getLoadedModules().map(
|
||||
(mod) => Object.keys(mod)[0]
|
||||
)
|
||||
|
||||
const allLinksToLoad = Object.values(linkDefinitions).concat(
|
||||
modulesDefinition ?? []
|
||||
)
|
||||
|
||||
const allLinks = new Set<string>()
|
||||
for (const definition of allLinksToLoad) {
|
||||
if (definition.isReadOnlyLink) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (definition.relationships?.length !== 2) {
|
||||
throw new Error(
|
||||
`Link module ${definition.serviceName} must have 2 relationships.`
|
||||
)
|
||||
}
|
||||
|
||||
const [primary, foreign] = definition.relationships ?? []
|
||||
const serviceKey = lowerCaseFirst(
|
||||
definition.serviceName ??
|
||||
composeLinkName(
|
||||
primary.serviceName,
|
||||
primary.foreignKey,
|
||||
foreign.serviceName,
|
||||
foreign.foreignKey
|
||||
)
|
||||
)
|
||||
|
||||
if (modulesLoadedKeys.includes(serviceKey)) {
|
||||
continue
|
||||
} else if (allLinks.has(serviceKey)) {
|
||||
throw new Error(`Link module ${serviceKey} already exists.`)
|
||||
}
|
||||
|
||||
allLinks.add(serviceKey)
|
||||
|
||||
if (
|
||||
!modulesLoadedKeys.includes(primary.serviceName) ||
|
||||
!modulesLoadedKeys.includes(foreign.serviceName)
|
||||
) {
|
||||
// TODO: This should be uncommented when all modules are done
|
||||
// continue
|
||||
}
|
||||
|
||||
const migrate = getMigration(definition, serviceKey, primary, foreign)
|
||||
await migrate({ options, logger })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
JoinerRelationship,
|
||||
ModuleExports,
|
||||
ModuleJoinerConfig,
|
||||
} from "@medusajs/types"
|
||||
import { getModuleService, getReadOnlyModuleService } from "@services"
|
||||
import { getLoaders } from "../loaders"
|
||||
|
||||
export function getLinkModuleDefinition(
|
||||
joinerConfig: ModuleJoinerConfig,
|
||||
primary: JoinerRelationship,
|
||||
foreign: JoinerRelationship
|
||||
): ModuleExports {
|
||||
return {
|
||||
service: joinerConfig.isReadOnlyLink
|
||||
? getReadOnlyModuleService(joinerConfig)
|
||||
: getModuleService(joinerConfig),
|
||||
loaders: getLoaders({
|
||||
joinerConfig,
|
||||
primary,
|
||||
foreign,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
import { composeLinkName } from "./utils"
|
||||
|
||||
export const LINKS = {
|
||||
ProductVariantInventoryItem: composeLinkName(
|
||||
Modules.PRODUCT,
|
||||
"variant_id",
|
||||
Modules.INVENTORY,
|
||||
"inventory_item_id"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
InternalModuleDeclaration,
|
||||
LoaderOptions,
|
||||
ModuleServiceInitializeCustomDataLayerOptions,
|
||||
ModuleServiceInitializeOptions,
|
||||
} from "@medusajs/modules-sdk"
|
||||
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||
|
||||
import { EntitySchema } from "@mikro-orm/core"
|
||||
|
||||
export function connectionLoader(entity: EntitySchema) {
|
||||
return async (
|
||||
{
|
||||
options,
|
||||
container,
|
||||
logger,
|
||||
}: LoaderOptions<
|
||||
| ModuleServiceInitializeOptions
|
||||
| ModuleServiceInitializeCustomDataLayerOptions
|
||||
>,
|
||||
moduleDeclaration?: InternalModuleDeclaration
|
||||
): Promise<void> => {
|
||||
await ModulesSdkUtils.mikroOrmConnectionLoader({
|
||||
entities: [entity],
|
||||
container,
|
||||
options,
|
||||
moduleDeclaration,
|
||||
logger,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseRepository, getLinkRepository } from "@repositories"
|
||||
import { LinkService, getModuleService } from "@services"
|
||||
|
||||
import { LoaderOptions } from "@medusajs/modules-sdk"
|
||||
import {
|
||||
InternalModuleDeclaration,
|
||||
ModuleJoinerConfig,
|
||||
ModulesSdkTypes,
|
||||
} from "@medusajs/types"
|
||||
import { asClass, asValue } from "awilix"
|
||||
|
||||
export function containerLoader(entity, joinerConfig: ModuleJoinerConfig) {
|
||||
return async (
|
||||
{
|
||||
options,
|
||||
container,
|
||||
}: LoaderOptions<
|
||||
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||
>,
|
||||
moduleDeclaration?: InternalModuleDeclaration
|
||||
): Promise<void> => {
|
||||
const [primary, foreign] = joinerConfig.relationships!
|
||||
|
||||
container.register({
|
||||
joinerConfig: asValue(joinerConfig),
|
||||
primaryKey: asValue(primary.foreignKey.split(",")),
|
||||
foreignKey: asValue(foreign.foreignKey),
|
||||
extraFields: asValue(
|
||||
Object.keys(joinerConfig.databaseConfig?.extraFields || {})
|
||||
),
|
||||
|
||||
linkModuleService: asClass(getModuleService(joinerConfig)).singleton(),
|
||||
linkService: asClass(LinkService).singleton(),
|
||||
|
||||
baseRepository: asClass(BaseRepository).singleton(),
|
||||
linkRepository: asClass(getLinkRepository(entity)).singleton(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
JoinerRelationship,
|
||||
ModuleJoinerConfig,
|
||||
ModuleLoaderFunction,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import { generateEntity } from "../utils"
|
||||
import { connectionLoader } from "./connection"
|
||||
import { containerLoader } from "./container"
|
||||
|
||||
export function getLoaders({
|
||||
joinerConfig,
|
||||
primary,
|
||||
foreign,
|
||||
}: {
|
||||
joinerConfig: ModuleJoinerConfig
|
||||
primary: JoinerRelationship
|
||||
foreign: JoinerRelationship
|
||||
}): ModuleLoaderFunction[] {
|
||||
if (joinerConfig.isReadOnlyLink) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entity = generateEntity(joinerConfig, primary, foreign)
|
||||
return [connectionLoader(entity), containerLoader(entity, joinerConfig)]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
JoinerRelationship,
|
||||
LoaderOptions,
|
||||
Logger,
|
||||
ModuleJoinerConfig,
|
||||
ModuleServiceInitializeOptions,
|
||||
} from "@medusajs/types"
|
||||
import { generateEntity } from "../utils"
|
||||
|
||||
import { DALUtils, ModulesSdkUtils } from "@medusajs/utils"
|
||||
|
||||
export function getMigration(
|
||||
joinerConfig: ModuleJoinerConfig,
|
||||
serviceName: string,
|
||||
primary: JoinerRelationship,
|
||||
foreign: JoinerRelationship
|
||||
) {
|
||||
return async function runMigrations(
|
||||
{
|
||||
options,
|
||||
logger,
|
||||
}: Pick<
|
||||
LoaderOptions<ModuleServiceInitializeOptions>,
|
||||
"options" | "logger"
|
||||
> = {} as any
|
||||
) {
|
||||
logger ??= console as unknown as Logger
|
||||
|
||||
const dbData = ModulesSdkUtils.loadDatabaseConfig("link_modules", options)
|
||||
const entity = generateEntity(joinerConfig, primary, foreign)
|
||||
|
||||
const orm = await DALUtils.mikroOrmCreateConnection(dbData, [entity])
|
||||
|
||||
const tableName = entity.meta.collection
|
||||
|
||||
let hasTable = false
|
||||
try {
|
||||
await orm.em.getConnection().execute(`SELECT 1 FROM ${tableName} LIMIT 0`)
|
||||
hasTable = true
|
||||
} catch {}
|
||||
|
||||
const generator = orm.getSchemaGenerator()
|
||||
if (hasTable) {
|
||||
const updateSql = await generator.getUpdateSchemaSQL()
|
||||
const entityUpdates = updateSql
|
||||
.split(";")
|
||||
.map((sql) => sql.trim())
|
||||
.filter((sql) =>
|
||||
sql.toLowerCase().includes(`alter table "${tableName.toLowerCase()}"`)
|
||||
)
|
||||
|
||||
if (entityUpdates.length > 0) {
|
||||
try {
|
||||
await generator.execute(entityUpdates.join(";"))
|
||||
logger.info(`Link module "${serviceName}" migration executed`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Link module "${serviceName}" migration failed to run - Error: ${error}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.info(`Skipping "${tableName}" migration.`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await generator.createSchema()
|
||||
|
||||
logger.info(`Link module "${serviceName}" migration executed`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Link module "${serviceName}" migration failed to run - Error: ${error}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await orm.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||
export { getLinkRepository } from "./link"
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Context, FindOptions, ModuleJoinerConfig } from "@medusajs/types"
|
||||
import {
|
||||
EntitySchema,
|
||||
LoadStrategy,
|
||||
FilterQuery as MikroFilterQuery,
|
||||
FindOptions as MikroOptions,
|
||||
} from "@mikro-orm/core"
|
||||
|
||||
import {
|
||||
MikroOrmAbstractBaseRepository,
|
||||
generateEntityId,
|
||||
} from "@medusajs/utils"
|
||||
import { SqlEntityManager } from "@mikro-orm/postgresql"
|
||||
|
||||
export function getLinkRepository(model: EntitySchema) {
|
||||
return class LinkRepository extends MikroOrmAbstractBaseRepository<object> {
|
||||
readonly manager_: SqlEntityManager
|
||||
readonly model_: EntitySchema
|
||||
readonly joinerConfig_: ModuleJoinerConfig
|
||||
|
||||
constructor({
|
||||
manager,
|
||||
joinerConfig,
|
||||
}: {
|
||||
manager: SqlEntityManager
|
||||
joinerConfig: ModuleJoinerConfig
|
||||
}) {
|
||||
// @ts-ignore
|
||||
super(...arguments)
|
||||
this.manager_ = manager
|
||||
this.model_ = model
|
||||
this.joinerConfig_ = joinerConfig
|
||||
}
|
||||
|
||||
async find(
|
||||
findOptions: FindOptions<unknown> = { where: {} },
|
||||
context: Context = {}
|
||||
): Promise<unknown[]> {
|
||||
const manager = this.getActiveManager<SqlEntityManager>(context)
|
||||
|
||||
const findOptions_ = { ...findOptions }
|
||||
findOptions_.options ??= {}
|
||||
|
||||
Object.assign(findOptions_.options, {
|
||||
strategy: LoadStrategy.SELECT_IN,
|
||||
})
|
||||
|
||||
return await manager.find(
|
||||
this.model_,
|
||||
findOptions_.where as MikroFilterQuery<unknown>,
|
||||
findOptions_.options as MikroOptions<any>
|
||||
)
|
||||
}
|
||||
|
||||
async findAndCount(
|
||||
findOptions: FindOptions<object> = { where: {} },
|
||||
context: Context = {}
|
||||
): Promise<[object[], number]> {
|
||||
const manager = this.getActiveManager<SqlEntityManager>(context)
|
||||
|
||||
const findOptions_ = { ...findOptions }
|
||||
findOptions_.options ??= {}
|
||||
|
||||
Object.assign(findOptions_.options, {
|
||||
strategy: LoadStrategy.SELECT_IN,
|
||||
})
|
||||
|
||||
return await manager.findAndCount(
|
||||
this.model_,
|
||||
findOptions_.where as MikroFilterQuery<unknown>,
|
||||
findOptions_.options as MikroOptions<any>
|
||||
)
|
||||
}
|
||||
|
||||
async delete(data: any, context: Context = {}): Promise<void> {
|
||||
const filter = {}
|
||||
for (const key in data) {
|
||||
filter[key] = {
|
||||
$in: Array.isArray(data[key]) ? data[key] : [data[key]],
|
||||
}
|
||||
}
|
||||
|
||||
const manager = this.getActiveManager<SqlEntityManager>(context)
|
||||
await manager.nativeDelete(this.model_, data, {})
|
||||
}
|
||||
|
||||
async create(data: object[], context: Context = {}): Promise<object[]> {
|
||||
const manager = this.getActiveManager<SqlEntityManager>(context)
|
||||
|
||||
const links = data.map((link: any) => {
|
||||
link.id = generateEntityId(
|
||||
link.id,
|
||||
this.joinerConfig_.databaseConfig?.idPrefix ?? "link"
|
||||
)
|
||||
|
||||
return manager.create(this.model_, link)
|
||||
})
|
||||
|
||||
await manager.upsertMany(this.model_, links)
|
||||
|
||||
return links
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Constructor, ILinkModule, ModuleJoinerConfig } from "@medusajs/types"
|
||||
import { LinkModuleService } from "@services"
|
||||
|
||||
export function getModuleService(
|
||||
joinerConfig: ModuleJoinerConfig
|
||||
): Constructor<ILinkModule> {
|
||||
const joinerConfig_ = JSON.parse(JSON.stringify(joinerConfig))
|
||||
delete joinerConfig_.databaseConfig
|
||||
return class LinkService extends LinkModuleService<unknown> {
|
||||
override __joinerConfig(): ModuleJoinerConfig {
|
||||
return joinerConfig_ as ModuleJoinerConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getReadOnlyModuleService(joinerConfig: ModuleJoinerConfig) {
|
||||
return class ReadOnlyLinkService {
|
||||
__joinerConfig(): ModuleJoinerConfig {
|
||||
return joinerConfig as ModuleJoinerConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./dynamic-service-class"
|
||||
export { default as LinkService } from "./link"
|
||||
export { default as LinkModuleService } from "./link-module-service"
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
Context,
|
||||
DAL,
|
||||
FindConfig,
|
||||
ILinkModule,
|
||||
InternalModuleDeclaration,
|
||||
ModuleJoinerConfig,
|
||||
RestoreReturn,
|
||||
SoftDeleteReturn,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MapToConfig,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
ModulesSdkUtils,
|
||||
mapObjectTo,
|
||||
} from "@medusajs/utils"
|
||||
import { LinkService } from "@services"
|
||||
import { shouldForceTransaction } from "../utils"
|
||||
|
||||
type InjectedDependencies = {
|
||||
baseRepository: DAL.RepositoryService
|
||||
linkService: LinkService<any>
|
||||
primaryKey: string | string[]
|
||||
foreignKey: string
|
||||
extraFields: string[]
|
||||
}
|
||||
|
||||
export default class LinkModuleService<TLink> implements ILinkModule {
|
||||
protected baseRepository_: DAL.RepositoryService
|
||||
protected readonly linkService_: LinkService<TLink>
|
||||
protected primaryKey_: string[]
|
||||
protected foreignKey_: string
|
||||
protected extraFields_: string[]
|
||||
|
||||
constructor(
|
||||
{
|
||||
baseRepository,
|
||||
linkService,
|
||||
primaryKey,
|
||||
foreignKey,
|
||||
extraFields,
|
||||
}: InjectedDependencies,
|
||||
readonly moduleDeclaration: InternalModuleDeclaration
|
||||
) {
|
||||
this.baseRepository_ = baseRepository
|
||||
this.linkService_ = linkService
|
||||
this.primaryKey_ = !Array.isArray(primaryKey) ? [primaryKey] : primaryKey
|
||||
this.foreignKey_ = foreignKey
|
||||
this.extraFields_ = extraFields
|
||||
}
|
||||
|
||||
__joinerConfig(): ModuleJoinerConfig {
|
||||
return {} as ModuleJoinerConfig
|
||||
}
|
||||
|
||||
private buildData(
|
||||
primaryKeyData: string | string[],
|
||||
foreignKeyData: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
if (this.primaryKey_.length > 1) {
|
||||
if (
|
||||
!Array.isArray(primaryKeyData) ||
|
||||
primaryKeyData.length !== this.primaryKey_.length
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Primary key data must be an array ${this.primaryKey_.length} values`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pk = this.primaryKey_.join(",")
|
||||
return {
|
||||
[pk]: primaryKeyData,
|
||||
[this.foreignKey_]: foreignKeyData,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
private isValidKeyName(name: string) {
|
||||
return this.primaryKey_.concat(this.foreignKey_).includes(name)
|
||||
}
|
||||
|
||||
private validateFields(data: any) {
|
||||
const keys = Object.keys(data)
|
||||
if (!keys.every((k) => this.isValidKeyName(k))) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Invalid field name provided. Valid field names are ${this.primaryKey_.concat(
|
||||
this.foreignKey_
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@InjectManager("baseRepository_")
|
||||
async retrieve(
|
||||
primaryKeyData: string | string[],
|
||||
foreignKeyData: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<unknown> {
|
||||
const filter = this.buildData(primaryKeyData, foreignKeyData)
|
||||
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(filter)
|
||||
const entry = await this.linkService_.list(queryOptions, {}, sharedContext)
|
||||
|
||||
if (!entry?.length) {
|
||||
const pk = this.primaryKey_.join(",")
|
||||
const errMessage = `${pk}[${primaryKeyData}] and ${this.foreignKey_}[${foreignKeyData}]`
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Entry ${errMessage} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return entry[0]
|
||||
}
|
||||
|
||||
@InjectManager("baseRepository_")
|
||||
async list(
|
||||
filters: Record<string, unknown> = {},
|
||||
config: FindConfig<unknown> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<unknown[]> {
|
||||
const rows = await this.linkService_.list(filters, config, sharedContext)
|
||||
|
||||
return await this.baseRepository_.serialize<object[]>(rows)
|
||||
}
|
||||
|
||||
@InjectManager("baseRepository_")
|
||||
async listAndCount(
|
||||
filters: Record<string, unknown> = {},
|
||||
config: FindConfig<unknown> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[unknown[], number]> {
|
||||
const [rows, count] = await this.linkService_.listAndCount(
|
||||
filters,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return [await this.baseRepository_.serialize<object[]>(rows), count]
|
||||
}
|
||||
|
||||
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
|
||||
async create(
|
||||
primaryKeyOrBulkData:
|
||||
| string
|
||||
| string[]
|
||||
| [string | string[], string, Record<string, unknown>][],
|
||||
foreignKeyData?: string,
|
||||
extraFields?: Record<string, unknown>,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
) {
|
||||
const data: unknown[] = []
|
||||
if (foreignKeyData === undefined && Array.isArray(primaryKeyOrBulkData)) {
|
||||
for (const [primaryKey, foreignKey, extra] of primaryKeyOrBulkData) {
|
||||
data.push(
|
||||
this.buildData(
|
||||
primaryKey as string | string[],
|
||||
foreignKey as string,
|
||||
extra as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
data.push(
|
||||
this.buildData(
|
||||
primaryKeyOrBulkData as string | string[],
|
||||
foreignKeyData!,
|
||||
extraFields
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const links = await this.linkService_.create(data, sharedContext)
|
||||
|
||||
return await this.baseRepository_.serialize<object[]>(links)
|
||||
}
|
||||
|
||||
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
|
||||
async dismiss(
|
||||
primaryKeyOrBulkData: string | string[] | [string | string[], string][],
|
||||
foreignKeyData?: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
) {
|
||||
const data: unknown[] = []
|
||||
if (foreignKeyData === undefined && Array.isArray(primaryKeyOrBulkData)) {
|
||||
for (const [primaryKey, foreignKey] of primaryKeyOrBulkData) {
|
||||
data.push(this.buildData(primaryKey, foreignKey as string))
|
||||
}
|
||||
} else {
|
||||
data.push(
|
||||
this.buildData(
|
||||
primaryKeyOrBulkData as string | string[],
|
||||
foreignKeyData!
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const links = await this.linkService_.dismiss(data, sharedContext)
|
||||
|
||||
return await this.baseRepository_.serialize<object[]>(links)
|
||||
}
|
||||
|
||||
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
|
||||
async delete(
|
||||
data: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
this.validateFields(data)
|
||||
|
||||
await this.linkService_.delete(data, sharedContext)
|
||||
}
|
||||
|
||||
async softDelete(
|
||||
data: any,
|
||||
{ returnLinkableKeys }: SoftDeleteReturn = {},
|
||||
sharedContext: Context = {}
|
||||
): Promise<Record<string, unknown[]> | void> {
|
||||
this.validateFields(data)
|
||||
|
||||
let [, cascadedEntitiesMap] = await this.softDelete_(data, sharedContext)
|
||||
|
||||
const pk = this.primaryKey_.join(",")
|
||||
const entityNameToLinkableKeysMap: MapToConfig = {
|
||||
LinkModel: [
|
||||
{ mapTo: pk, valueFrom: pk },
|
||||
{ mapTo: this.foreignKey_, valueFrom: this.foreignKey_ },
|
||||
],
|
||||
}
|
||||
|
||||
let mappedCascadedEntitiesMap
|
||||
if (returnLinkableKeys) {
|
||||
// Map internal table/column names to their respective external linkable keys
|
||||
// eg: product.id = product_id, variant.id = variant_id
|
||||
mappedCascadedEntitiesMap = mapObjectTo<Record<string, string[]>>(
|
||||
cascadedEntitiesMap,
|
||||
entityNameToLinkableKeysMap,
|
||||
{
|
||||
pick: returnLinkableKeys,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
|
||||
}
|
||||
|
||||
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
|
||||
protected async softDelete_(
|
||||
data: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[string[], Record<string, string[]>]> {
|
||||
return await this.linkService_.softDelete(data, sharedContext)
|
||||
}
|
||||
|
||||
async restore(
|
||||
data: any,
|
||||
{ returnLinkableKeys }: RestoreReturn = {},
|
||||
sharedContext: Context = {}
|
||||
): Promise<Record<string, unknown[]> | void> {
|
||||
this.validateFields(data)
|
||||
|
||||
let [, cascadedEntitiesMap] = await this.restore_(data, sharedContext)
|
||||
|
||||
const pk = this.primaryKey_.join(",")
|
||||
const entityNameToLinkableKeysMap: MapToConfig = {
|
||||
LinkModel: [
|
||||
{ mapTo: pk, valueFrom: pk },
|
||||
{ mapTo: this.foreignKey_, valueFrom: this.foreignKey_ },
|
||||
],
|
||||
}
|
||||
|
||||
let mappedCascadedEntitiesMap
|
||||
if (returnLinkableKeys) {
|
||||
// Map internal table/column names to their respective external linkable keys
|
||||
// eg: product.id = product_id, variant.id = variant_id
|
||||
mappedCascadedEntitiesMap = mapObjectTo<Record<string, string[]>>(
|
||||
cascadedEntitiesMap,
|
||||
entityNameToLinkableKeysMap,
|
||||
{
|
||||
pick: returnLinkableKeys,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
|
||||
}
|
||||
|
||||
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
|
||||
async restore_(
|
||||
data: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[string[], Record<string, string[]>]> {
|
||||
return await this.linkService_.restore(data, sharedContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Context, FindConfig } from "@medusajs/types"
|
||||
import {
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
ModulesSdkUtils,
|
||||
} from "@medusajs/utils"
|
||||
import { doNotForceTransaction } from "../utils"
|
||||
|
||||
type InjectedDependencies = {
|
||||
linkRepository: any
|
||||
}
|
||||
|
||||
export default class LinkService<TEntity> {
|
||||
protected readonly linkRepository_: any
|
||||
|
||||
constructor({ linkRepository }: InjectedDependencies) {
|
||||
this.linkRepository_ = linkRepository
|
||||
}
|
||||
|
||||
@InjectManager("linkRepository_")
|
||||
async list(
|
||||
filters: unknown = {},
|
||||
config: FindConfig<unknown> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TEntity[]> {
|
||||
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(
|
||||
filters as any,
|
||||
config
|
||||
)
|
||||
return await this.linkRepository_.find(queryOptions, sharedContext)
|
||||
}
|
||||
|
||||
@InjectManager("linkRepository_")
|
||||
async listAndCount(
|
||||
filters = {},
|
||||
config: FindConfig<unknown> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[TEntity[], number]> {
|
||||
const queryOptions = ModulesSdkUtils.buildQuery<unknown>(filters, config)
|
||||
return await this.linkRepository_.findAndCount(queryOptions, sharedContext)
|
||||
}
|
||||
|
||||
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
|
||||
async create(
|
||||
data: unknown[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TEntity[]> {
|
||||
return await this.linkRepository_.create(data, {
|
||||
transactionManager: sharedContext.transactionManager,
|
||||
})
|
||||
}
|
||||
|
||||
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
|
||||
async dismiss(
|
||||
data: unknown[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TEntity[]> {
|
||||
const filter: any = []
|
||||
for (const pair of data) {
|
||||
filter.push({
|
||||
$and: Object.entries(pair as object).map(([key, value]) => ({
|
||||
[key]: value,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const [rows] = await this.linkRepository_.softDelete(
|
||||
{ $or: filter },
|
||||
{
|
||||
transactionManager: sharedContext.transactionManager,
|
||||
}
|
||||
)
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
|
||||
async delete(
|
||||
data: unknown,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
await this.linkRepository_.delete(data, {
|
||||
transactionManager: sharedContext.transactionManager,
|
||||
})
|
||||
}
|
||||
|
||||
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
|
||||
async softDelete(
|
||||
data: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[string[], Record<string, string[]>]> {
|
||||
const filter = {}
|
||||
for (const key in data) {
|
||||
filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] }
|
||||
}
|
||||
|
||||
return await this.linkRepository_.softDelete(filter, {
|
||||
transactionManager: sharedContext.transactionManager,
|
||||
})
|
||||
}
|
||||
|
||||
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
|
||||
async restore(
|
||||
data: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[string[], Record<string, string[]>]> {
|
||||
const filter = {}
|
||||
for (const key in data) {
|
||||
filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] }
|
||||
}
|
||||
|
||||
return await this.linkRepository_.restore(data, {
|
||||
transactionManager: sharedContext.transactionManager,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Logger } from "@medusajs/types"
|
||||
|
||||
export type InitializeModuleInjectableDependencies = {
|
||||
logger?: Logger
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { lowerCaseFirst, toPascalCase } from "@medusajs/utils"
|
||||
|
||||
export const composeLinkName = (...args) => {
|
||||
return lowerCaseFirst(toPascalCase(composeTableName(...args.concat("link"))))
|
||||
}
|
||||
|
||||
export const composeTableName = (...args) => {
|
||||
return args.map((name) => name.replace(/(_id|Service)$/gi, "")).join("_")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { JoinerRelationship, ModuleJoinerConfig } from "@medusajs/types"
|
||||
import {
|
||||
SoftDeletableFilterKey,
|
||||
mikroOrmSoftDeletableFilterOptions,
|
||||
simpleHash,
|
||||
} from "@medusajs/utils"
|
||||
|
||||
import { EntitySchema } from "@mikro-orm/core"
|
||||
import { composeTableName } from "./compose-link-name"
|
||||
|
||||
function getClass(...properties) {
|
||||
return class LinkModel {
|
||||
constructor(...values) {
|
||||
properties.forEach((name, idx) => {
|
||||
this[name] = values[idx]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateEntity(
|
||||
joinerConfig: ModuleJoinerConfig,
|
||||
primary: JoinerRelationship,
|
||||
foreign: JoinerRelationship
|
||||
) {
|
||||
const fieldNames = primary.foreignKey.split(",").concat(foreign.foreignKey)
|
||||
|
||||
const tableName =
|
||||
joinerConfig.databaseConfig?.tableName ??
|
||||
composeTableName(
|
||||
primary.serviceName,
|
||||
primary.foreignKey,
|
||||
foreign.serviceName,
|
||||
foreign.foreignKey
|
||||
)
|
||||
|
||||
const fields = fieldNames.reduce((acc, curr) => {
|
||||
acc[curr] = {
|
||||
type: "string",
|
||||
nullable: false,
|
||||
primary: true,
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const extraFields = joinerConfig.databaseConfig?.extraFields ?? {}
|
||||
|
||||
for (const column in extraFields) {
|
||||
fieldNames.push(column)
|
||||
|
||||
fields[column] = {
|
||||
type: extraFields[column].type,
|
||||
nullable: !!extraFields[column].nullable,
|
||||
defaultRaw: extraFields[column].defaultValue,
|
||||
...(extraFields[column].options ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
const hashTableName = simpleHash(tableName)
|
||||
|
||||
return new EntitySchema({
|
||||
class: getClass(
|
||||
...fieldNames.concat("created_at", "updated_at", "deleted_at")
|
||||
) as any,
|
||||
tableName,
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
nullable: false,
|
||||
},
|
||||
...fields,
|
||||
created_at: {
|
||||
type: "Date",
|
||||
nullable: false,
|
||||
defaultRaw: "CURRENT_TIMESTAMP",
|
||||
},
|
||||
updated_at: {
|
||||
type: "Date",
|
||||
nullable: false,
|
||||
defaultRaw: "CURRENT_TIMESTAMP",
|
||||
},
|
||||
deleted_at: { type: "Date", nullable: true },
|
||||
},
|
||||
filters: {
|
||||
[SoftDeletableFilterKey]: mikroOrmSoftDeletableFilterOptions,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
properties: ["id"],
|
||||
name: "IDX_id_" + hashTableName,
|
||||
},
|
||||
{
|
||||
properties: primary.foreignKey.split(","),
|
||||
name:
|
||||
"IDX_" +
|
||||
primary.foreignKey.split(",").join("_") +
|
||||
"_" +
|
||||
hashTableName,
|
||||
},
|
||||
{
|
||||
properties: foreign.foreignKey,
|
||||
name: "IDX_" + foreign.foreignKey + "_" + hashTableName,
|
||||
},
|
||||
{
|
||||
properties: ["deleted_at"],
|
||||
name: "IDX_deleted_at_" + hashTableName,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MODULE_RESOURCE_TYPE } from "@medusajs/types"
|
||||
|
||||
export * from "./compose-link-name"
|
||||
export * from "./generate-entity"
|
||||
|
||||
export function shouldForceTransaction(target: any): boolean {
|
||||
return target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED
|
||||
}
|
||||
|
||||
export function doNotForceTransaction(): boolean {
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user